Chapter 4. Customizing The User Login Experience
In this chapter, you’re going to build on your understanding of how to authenticate a user in the context of a Blazor WebAssembly application and customize the authentication experience. You’ll see a familiar web client startup configuration pattern and continue to explore a few other areas of the app, such as the registration of client-side services. From there, I’ll take your knowledge of JavaScript interoperability further with a compelling example, using browser native Speech Synthesis. You’ll learn how the app’s header functions and you’ll see a pattern for implementing modal dialogs, as a shared infrastructure within a small base component hierarchy. As part of this, you’ll learn how to write and handle custom events.
A Bit More On Blazor Authentication
When you use the app, your identity is used to uniquely identify you as a user of the app. This is true in most app scenarios, including the defaults for both Blazor hosting models when authentication is configured. A single user can log in from multiple clients to use the Learning Blazor application. When a user is authenticated, meaning that the user has entered their credentials or been redirected through an authentication workflow. These workflows define a series of sequential steps that must be followed precisely, and successfully to yield an authenticated user. Here are the basic steps:
-
Get an authorization code: Run the
/authorize
endpoint providing the requestedscope
, where the user interacts with the framework-provided UI. -
Get an access token: When successful, from the authorization code you’ll get a token from the
/token
endpoint. -
Use the token: Use the access token to make requests to the various HTTP Web APIs.
-
Refresh the token: Tokens expire. They’ll need to refresh the tokens automatically when required.
This can also be visualized, consider the following diagram that shows the Authentication user flow.
I’m not going to share how to create an AAD B2C tenant, that’s beyond the scope of this book. Besides, there are plenty of good resources for that sort of thing. For more information, see Microsoft Docs: Create an Azure Active Directory B2C tenant. Just know that a tenant exists, and it contains two app registrations. There’s a WebAssembly Client app configured as a SPA, and an API app configured as a server. It’s rather feature-rich, with the ability to customize the clients’ HTML workflow. As an admin, I configured what user scopes exist and what claims are returned/requested.
During the authentication process, the possible states are represented in authentication states listing later in this chapter.
The user is represented as a series of key/value pairs, called “claims”. The keys are named and fairly well standardized. The values are stored, maintained, and retrieved from the trusted third-party entity — also known as “Authentication Providers”, think Google, GitHub, Facebook, Microsoft, and Twitter.
Client-side Custom Authorization Message Handler Implementation
The Learning Blazor app defines a custom implementation of the AuthorizationMessageHandler
. In a Blazor WebAssembly app, you can attach tokens to outgoing requests using the framework-provided AuthorizationMessageHandler
type. Let’s take a look at the ApiAccessAuthorizationMessageHandler.cs C# file for its implementation:
namespace
Learning.Blazor.Handlers
;
public
sealed
class
ApiAccessAuthorizationMessageHandler
:
AuthorizationMessageHandler
{
public
ApiAccessAuthorizationMessageHandler
(
IAccessTokenProvider
provider
,
NavigationManager
navigation
,
IOptions
<
WebApiOptions
>
options
)
:
base
(
provider
,
navigation
)
=
>
ConfigureHandler
(
authorizedUrls
:
new
[
]
{
options
.
Value
.
WebApiServerUrl
,
options
.
Value
.
PwnedWebApiServerUrl
,
"https://learningblazor.b2clogin.com"
}
,
scopes
:
new
[
]
{
AzureAuthenticationTenant
.
ScopeUrl
}
)
;
}
The
ApiAccessAuthorizationMessageHandler
is a sealed class.Its constructor takes an
IAccessTokenProvider
,NavigationManager
, andIOptions<WebApiOptions>
parameters.The
base
constructor takes aIAccessTokenProvider
and aNavigationManager
.The
ConfigureHandler
method is called by the constructor, setting theauthorizedUrls
andscopes
properties.
The framework exposes an AuthorizationMessageHandler
. It can be registered as an HttpClient
instance HTTP message handler, ensuring that access tokens are appended to outgoing HTTP requests.
The implementation will need the configured IOptions<WebApiOptions>
abstraction. This code is requesting the dependency injection service provider to resolve a strongly-typed configuration object.
Subclasses should use the base class’s ConfigureHandler
method to configure themselves. The authorizedUrls
array is assigned given the Web API and Pwned Web API servers’ URLs. This implementation essentially takes a few configured URLs and sets them as the allow-listed URLs. It also configures an app-specific scope
URL, which is set as the handlers scopes
argument to the ConfigureHandler
function. This handler can then be added to an IHttpClientBuilder
instance using the AddHttpMessageHandler<ApiAccessAuthorizationMessageHandler>
fluent API call, where you map and configure an HttpClient
for dependency injection. This is shown later “The Web.Client ConfigureServices
Functionality”. All of the HTTP requests made from the configured HttpClient
instance will append the appropriate Authorization
header with the short-lived access token.
With C# 10’s constant interpolated strings, the tenant host and public app identifier are formatted along with the API requesting scope
. This is a const
value defined in a class
named AzureAuthenticationTenant
, as shown in the following AzureAuthenticationTenant.cs C# file:
namespace
Learning.Blazor
;
static
class
AzureAuthenticationTenant
{
const
string
TenantHost
=
"https://learningblazor.onmicrosoft.com"
;
const
string
TenantPublicAppId
=
"ee8868e7-73ad-41f1-88b4-dc698429c8d4"
;
/// <summary>
/// Gets a formatted string value
/// that represents the scope URL:
/// <c>{tenant-host}/{app-id}/User.ApiAccess</c>.
/// </summary>
internal
const
string
ScopeUrl
=
$
"{TenantHost}/{TenantPublicAppId}/User.ApiAccess"
;
}
The class is defined as static
, as I do not intend to let developers create an instance of my object. The object exposes a single const string
value named ScopeUrl
. The first const string
is the TenantHost
. The second const string
is the public application identifier (App Id), or TenantPublicAppId
. The ScopeUrl
value is formatted as the host and App Id, with an ending segment representing the scope specifier "User.ApiAccess"
.
This is just a utilitarian static class
, and it’s a welcome alternative to having a hardcoded URL in the source. This approach is preferable with each segment of the fully qualified URL specified as a name identifier. These named values are to represent the Learning Blazor Azure business-to-consumer (B2C) user scope. This configuration is handled in the section “The Web.Client ConfigureServices
Functionality”. Next, we’ll cover the customization of the client authorization user experience (UX).
Customizing The Client’s Authorization Experience
The client-side configuration will handle setting up the client’s front-end Blazor code to depend on specific services, clients, and authenticated endpoints. The user experiences an authentication flow, and while parts of that flow are configurable from Azure Active Directory (AAD) business-to-consumer (B2C), we’re also able to manage what the user experiences leading up to and returning from various states of the authentication flow. This is possible with the "/authentication/{action}"
page’s route template, and this belongs to the Authentication.razor markup:
@page "/authentication/{action}" @inherits LocalizableComponentBase
<
Authentication
>
<
div
class
=
"is-size-3"
>
<
RemoteAuthenticatorView
Action
=
@Action
LogOut
=
@LocalizedLogOutFragment
LogOutSucceeded
=
@LocalizedLoggedOutFragment
LogOutFailed
=
@LocalizedLogOutFailedFragment
LogInFailed
=
@LocalizedLogInFailedFragment
>
<
LoggingIn
>
<
LoadingIndicator
Message
=
@Localizer["CheckingLoginState"]
HideLogo
=
"true"
/
>
<
/
LoggingIn
>
<
CompletingLogOut
>
<
LoadingIndicator
Message
=
@Localizer["ProcessingLogoutCallback"]
HideLogo
=
"true"
/
>
<
/
CompletingLogOut
>
<
CompletingLoggingIn
>
<
LoadingIndicator
Message
=
@Localizer["CompletingLogin"]
HideLogo
=
"true"
/
>
<
/
CompletingLoggingIn
>
<
/
RemoteAuthenticatorView
>
<
/
div
>
The
Authentication
page renders aRemoteAuthenticatorView
component.Several component templates exist to render different fragments of the authentication flow.
Like most of the app’s components, the Authentication
page is a component that also @inherits LocalizableComponentBase
. This component is considered a page since it defines an @page "/authentication/{action}"
directive. This will be the component that is rendered when the client-side routing handles a navigation event in response to the browser’s URL requests /authentication/{action}
route where the {action}
corresponds to the state of the remote authentication flow.
The component markup wraps the framework-provided RemoteAuthenticatorView
component with a single div
and class
attribute to control the overall layout.
The RemoteAuthenticatorView
component itself is where the customization capability comes from. This component exposes templated render fragment parameters. It is with this capability that you can provide a custom experience for the following authentication flow states:
-
LogOut
: The UI to display while the log out event is being handled. -
LogOutSucceeded
: The UI to display while the log out succeeded event is being handled. -
LogOutFailed
: The UI to display while the log out failed event is being handled. -
LogInFailed
: The UI to display while the log in failed event is being handled. -
LoggingIn
: The UI to display while the logging in event is being handled. -
CompletingLogOut
: The UI to display while the completing log out event is being handled. -
CompletingLoggingIn
: The UI to display while the completing logging in event is being handled.
Since these are all framework-provided RenderFragment
types, we can customize what is rendered. We can assign to the RemoteAuthenticatorView
component’s parameter properties inline, or using multiple templated-parameter syntaxes. The LoggingIn
, CompletingLogOut
, and CompletingLoggingIn
parameters are assigned to using the markup syntax, where other components can be referenced directly.
These three parameters are assigned given the custom LoadingIndicator
component. The LoadingIndicator
component conditionally renders the Blazor logo along with the loading indicator message and animated/styled spinning icon. All states of the authentication flow hide the Blazor logo but they could choose to render it by setting the LoadingIndicator.HideLogo
parameter to false
. Each passes a localized text message to the loading indicator message. These three states are transitional, so when I was designing this approach I determined it best to use messaging that aligns with that expectation.
That’s not to say that you couldn’t just as easily, use humorous nonsense instead. The authentication flow state is only interesting when you’re learning about it the first few times, beyond that we’re all nerds here now — so let’s get creative! We could replace these states with random facts — who doesn’t love hearing something interesting? I’ll leave that to you, send me a pull request and I might just create a community-supported messaging list. The point is that it is entirely customizable. The following list contains the initial states that I’ve configured for the app:
-
LoggingIn
relies on the"CheckingLoginState"
localized message with the following value:"Reading about the amazing Ada Lovelace (world's first computer programmer)."
-
CompletingLogOut
relies on the"ProcessingLogoutCallback"
localized message:"Things aren't always as they seem."
-
CompletingLogin
relies on the"CompletingLogin"
localized message:"Plugging in the random wires lying around."
The Authentication
page component’s shadow uses a slightly different technique to satisfy the RenderFragment
delegate. Recall that a framework-provided RenderFragment
is a void
returning delegate
type, and it defines a RenderTreeBuilder
parameter. With that in mind, consider the Authentication.razor.cs C# file:
using
Microsoft.AspNetCore.Components.Rendering
;
namespace
Learning.Blazor.Pages
{
public
sealed
partial
class
Authentication
{
[Parameter]
public
string?
Action
{
get
;
set
;
}
=
null
!
;
private
void
LocalizedLogOutFragment
(
RenderTreeBuilder
builder
)
=
>
ParagraphElementWithLocalizedContent
(
builder
,
Localizer
,
"ProcessingLogout"
)
;
private
void
LocalizedLoggedOutFragment
(
RenderTreeBuilder
builder
)
=
>
ParagraphElementWithLocalizedContent
(
builder
,
Localizer
,
"YouAreLoggedOut"
)
;
private
RenderFragment
LocalizedLogInFailedFragment
(
string
errorMessage
)
=
>
ParagraphElementWithLocalizedErrorContent
(
errorMessage
,
Localizer
,
"ErrorLoggingInFormat"
)
;
private
RenderFragment
LocalizedLogOutFailedFragment
(
string
errorMessage
)
=
>
ParagraphElementWithLocalizedErrorContent
(
errorMessage
,
Localizer
,
"ErrorLoggingOutFormat"
)
;
private
static
void
ParagraphElementWithLocalizedContent
(
RenderTreeBuilder
builder
,
CoalescingStringLocalizer
<
Authentication
>
localizer
,
string
resourceKey
)
{
builder
.
OpenElement
(
0
,
"p"
)
;
builder
.
AddContent
(
1
,
localizer
[
resourceKey
]
)
;
builder
.
CloseElement
(
)
;
}
private
static
RenderFragment
ParagraphElementWithLocalizedErrorContent
(
string
errorMessage
,
CoalescingStringLocalizer
<
Authentication
>
localizer
,
string
resourceKey
)
=
>
builder
=
>
{
builder
.
OpenElement
(
0
,
"p"
)
;
builder
.
AddContent
(
1
,
localizer
[
resourceKey
,
errorMessage
]
)
;
builder
.
CloseElement
(
)
;
}
;
}
The component uses the
Rendering
namespace to consumeRenderTreeBuilder
andRenderFragment
types.Authentication
page has several states.Each method either satisfies the
RenderFragment
delegate signature or returns aRenderFragment
type.A localized message is rendered when the authentication flow state has failed to log in.
The
ParagraphElementWithLocalizedContent
method create ap
element with a localized message.ParagraphElementWithLocalizedErrorContent
method differs by accepting a formattable error message.
The RenderFragment
, RenderFragment<T>
, and RenderTreeBuilder
types were first discussed in the framework-provided RenderFragment
type section of chapter two and are part of Microsoft.AspNetCore.Components.Rendering
namespace, while the Authentication
page component is in Learning.Blazor.Pages
.
The Authentication
page component is opaque in that it defines a string
property named Action
, and binds it to the framework-provided RemoteAuthenticatorView.Action
property of the same name. This component is also a partial class
, serving as the markup’s shadow with code-behind.
The LocalizedLogOutFragment
method is private
, however; the partial class
markup component has access to it. This method is assigned to the rendering responsibility when the client browser has finished handling the log out authentication flow. Its parameter is the RenderTreeBuilder builder
instance. The builder is immediately passed to the ParagraphElementWithLocalizedContent
method, along with the Localizer
, and a const string value of "ProcessingLogout"
. This pattern is repeated for the LocalizedLoggedOutFragment
method delegating to the same helper function, changing only the third parameter to "YouAreLoggedOut"
. These two methods are void
returning and RenderTreeBuilder
parameter-accepting. This means that they match the RenderFragment
delegate expected signature.
For education, I’ll show a few more ways to customize using a slightly different approach. Notice that the LocalizedLogInFailedFragment
is not void
returning, nor are they RenderTreeBuilder
parameter-accepting. Instead, this method returns a RenderFragment
and accepts a string
. This is possible as there are two RenderFragment
delegates:
-
delegate void RenderFragment(RenderTreeBuilder builder);
-
delegate RenderFragment RenderFragment<TValue>(TValue value);
The ParagraphElementWithLocalizedContent
method uses the RenderTreeBuilder builder
, CoalescingStringLocalizer<Authentication> localizer
, and string resourceKey
parameters. Using the builder
an opening <p>
HTML element is built. Content is added given the value of the localizer[resourceKey]
evaluation. Finally, the closing </p>
HTML element is built. This method is being used by the log out and logged out authentication flow events:
-
"ProcessingLogout"
renders the “If you’re not changing the world, you’re standing still.” message. -
"YouAreLoggedOut"
renders the “Bye for now!” message.
The ParagraphElementWithLocalizedErrorContent
method is very similar to the ParagraphElementWithLocalizedContent
method in that it defines identical parameters, but return different things. In this case the generic RenderFragment<string>
delegate type is inferred, even though the RenderFragment
delegate type is explicitly returned. This method is being used by the log in failed and log out failed authentication flow events:
-
When login fails, display a formatted message of:
"There was an error trying to log you in: '{0}'"
. -
When logout fails, display a formatted message of:
"There was an error trying to log you out: '{0}'"
.
The {0}
values within the message formats are used as placeholders for the raw and untranslated error messages.
The Web.Client ConfigureServices
Functionality
You should recall the common nomenclature of the top-level WebAssembly app entry point, a C# top-level program. This was initially shown in Link to Come and covered the ConfigureServices
extension method. We didn’t discuss the specifics of the client-side service registration. A majority of that work happens in the WebAssemblyHostBuilderExtensions.cs C# file:
namespace
Learning.Blazor.Extensions
;
internal
static
class
WebAssemblyHostBuilderExtensions
{
internal
static
WebAssemblyHostBuilder
ConfigureServices
(
this
WebAssemblyHostBuilder
builder
)
{
var
(
services
,
configuration
)
=
(
builder
.
Services
,
builder
.
Configuration
)
;
services
.
AddMemoryCache
(
)
;
services
.
AddScoped
<
ApiAccessAuthorizationMessageHandler
>
(
)
;
services
.
Configure
<
WebApiOptions
>
(
configuration
.
GetSection
(
nameof
(
WebApiOptions
)
)
)
;
static
WebApiOptions
?
GetWebApiOptions
(
IServiceProvider
serviceProvider
)
=
>
serviceProvider
.
GetService
<
IOptions
<
WebApiOptions
>
>
(
)
?
.
Value
;
var
addHttpClient
=
static
IHttpClientBuilder
(
IServiceCollection
services
,
string
httpClientName
,
Func
<
WebApiOptions
?
,
string?
>
webApiOptionsUrlFactory
)
=
>
services
.
AddHttpClient
(
httpClientName
,
(
serviceProvider
,
client
)
=
>
{
var
options
=
GetWebApiOptions
(
serviceProvider
)
;
var
apiUrl
=
webApiOptionsUrlFactory
(
options
)
;
if
(
apiUrl
is
{
Length
:
>
0
}
)
client
.
BaseAddress
=
new
Uri
(
apiUrl
)
;
var
cultureService
=
serviceProvider
.
GetRequiredService
<
CultureService
>
(
)
;
client
.
DefaultRequestHeaders
.
AcceptLanguage
.
ParseAdd
(
cultureService
.
CurrentCulture
.
TwoLetterISOLanguageName
)
;
}
)
.
AddHttpMessageHandler
<
ApiAccessAuthorizationMessageHandler
>
(
)
;
_
=
addHttpClient
(
services
,
HttpClientNames
.
ServerApi
,
options
=
>
options
?
.
WebApiServerUrl
)
;
_
=
addHttpClient
(
services
,
HttpClientNames
.
PwnedServerApi
,
options
=
>
options
?
.
PwnedWebApiServerUrl
)
;
_
=
addHttpClient
(
services
,
HttpClientNames
.
WebFunctionsApi
,
options
=
>
options
?
.
WebFunctionsUrl
?
?
builder
.
HostEnvironment
.
BaseAddress
)
;
services
.
AddScoped
<
WeatherFunctionsClientService
>
(
)
;
services
.
AddScoped
(
sp
=
>
sp
.
GetRequiredService
<
IHttpClientFactory
>
(
)
.
CreateClient
(
HttpClientNames
.
ServerApi
)
)
;
services
.
AddLocalization
(
)
;
services
.
AddMsalAuthentication
(
options
=
>
{
configuration
.
Bind
(
"AzureAdB2C"
,
options
.
ProviderOptions
.
Authentication
)
;
options
.
ProviderOptions
.
LoginMode
=
"redirect"
;
var
add
=
options
.
ProviderOptions
.
DefaultAccessTokenScopes
.
Add
;
add
(
"openid"
)
;
add
(
"offline_access"
)
;
add
(
AzureAuthenticationTenant
.
ScopeUrl
)
;
}
)
;
services
.
AddOptions
(
)
;
services
.
AddAuthorizationCore
(
)
;
services
.
AddSingleton
<
SharedHubConnection
>
(
)
;
services
.
AddSingleton
<
AppInMemoryState
>
(
)
;
services
.
AddSingleton
<
CultureService
>
(
)
;
services
.
AddSingleton
(
typeof
(
CoalescingStringLocalizer
<
>
)
)
;
services
.
AddScoped
<
IWeatherStringFormatterService
,
WeatherStringFormatterService
>
(
)
;
services
.
AddScoped
<
GeoLocationService
>
(
)
;
services
.
AddHttpClient
<
GeoLocationService
>
(
client
=
>
{
var
apiHost
=
"https://api.bigdatacloud.net"
;
var
reverseGeocodeClientRoute
=
"data/reverse-geocode-client"
;
client
.
BaseAddress
=
new
Uri
(
$
"{apiHost}/{reverseGeocodeClientRoute}"
)
;
client
.
DefaultRequestHeaders
.
AcceptEncoding
.
ParseAdd
(
"gzip"
)
;
}
)
;
services
.
AddJokeServices
(
)
;
services
.
AddLocalStorageServices
(
)
;
services
.
AddSpeechRecognitionServices
(
)
;
return
builder
;
}
}
The
(IServiceCollection services, IConfiguration configuration)
tuple is being used to capture theservices
andconfiguration
as locals.A static local function
addHttpClient
is defined.The
IHttpClientFactory
is being added as a singleton.The geolocation API has its
HttpClient
configured.
The file-scoped namespace is Learning.Blazor.Extensions
which shares all extension’s functionality for the client code. The extensions class is internal
and like all extensions classes, it is required to be static
. The ConfigureServices
method is named this way as it might seem familiar to ASP.NET Core developers who were accustomed to startup conventions, but it doesn’t have to be named this way. To allow for method chaining, this extension method returns the WebAssemblyHostBuilder
object that it extends.
Declare and assign the services
and configuration
objects from the builder
. Then it’s off to the races, we add the scoped aforementioned ApiAccessAuthorizationMessageHandler
as a service. The WebApiOptions
instance is configured, essentially binding them from the resolved configuration
instance’s WebApiOptions
object. There is a static local function named GetWebApiOptions
that returns a questionable WebApiOptions
object given an IServiceProvider
instance.
To avoid duplicating code, the addHttpClient
is a static local function that encapsulates the adding and configuring of an HTTP client. It returns an IHttpClientBuilder
instance given the services
, an httpClientName
, and a function that acts as a factory. The function is named webApiOptionsUrlFactory
and it returns a nullable string given the configured options object. The lambda expression delegates to the AddHttpClient
extension method on the IServiceCollection
type. This configures the HTTP client
base address, from the configured URL. It also sets the "Accept-Language"
default request header to the currently configured CultureService
instance’s ISO 639-1 two-letter code. There are two calls to this addHttpClient
expression, setting up the Web API server endpoint and the “Have I Been Pwned” server endpoint.
A few additional services are added, and the Microsoft Authentication Library (MSAL) services are configured and bound to the "AzureAdB2C"
section of the configuration
instance. The LoginMode
is assigned to "redirect"
, which causes the app to redirect the user to AAD B2C to complete sign-in. Another example of the improvements to lambda expressions is how we declare and assign a variable named add
, which delegates to the default access token scopes Add
to the collection method. It expects a string and is void
returning. The add
variable is then invoked three times, adding the "openid"
, "offline_access"
and ScopeUrl
scopes. Many of the remaining services are then registered.
An HttpClient
is added and configured, which will be used when DI resolves the GeoLocationService
. The big data cloud, API host, and route are used as the base address for the client
. The additional dependencies are then registered, which include the Joke Services and Local Storage packages. An IJSInProcessRuntime
is registered as a single instance, resolved by a cast from the IJSRuntime
. This is only possible with Blazor WebAssembly. This is discussed in much more detail in Chapter 7. Finally, the builder
is returned completing the fluent ConfigureServices
API.
This single extension method is the code that is responsible for configuring the dependency injection of the client-side app. You will have noticed that the HTTP message handler was configured for the HttpClient
instances that will forward the bearer tokens on behalf of the client from the ApiAccessAuthorizationMessageHandler
. This is important, as not all API endpoints require an authenticated user but those that do will only be accessible when correctly configured this way.
Native Speech Synthesis
You’ve seen how to register all the client-side services for dependency injection, and how to consume registered services in components. In the previous chapter, you saw how the home page renders its tiled content. If you recall, each tile had some markup that included the AdditiveSpeechComponent
. While I showed you how to consume this component, I didn’t yet expand upon how it works. Any component that attaches to the AdditiveSpeechComponent
will be able to use a native speech synthesis service. Clicking the audio button will trigger the speech synthesis service to speak the text of the tile as shown in Figure 4-2.
The AdditiveSpeechComponent
exposes a single Message
parameter. The consuming components reference this component and assign a message. Consider the AdditiveSpeechComponent.razor markup file:
@inherits LocalizableComponentBase<
AdditiveSpeechComponent
>
<
div
class
=
"is-top-right-overlay"
>
<
button
class
=
"button is-rounded is-theme-aware-button p-4 @_dynamicCSS"
disabled
=
@_isSpeaking
@
onclick
=
OnSpeakButtonClickAsync
>
<
span
class
=
"icon is-small"
>
<
i
class
=
"fas fa-volume-up"
></
i
>
</
span
>
</
button
>
</
div
>
The AdditiveSpeechComponent
inherits the LocalizableComponentBase
to use three common services which are injected into the base class. The AppInMemoryState
, CultureService
, and IJSRuntime
services are common enough to warrant this inheritance.
The markup is a div
element with a descriptive class
attribute, which overlays the element in the top-right hand corner of the consuming component. The div
element is a parent to a rounded and theme-aware button
with a bit of dynamic CSS. The button itself is disabled
when the _isSpeaking
bit evaluates as true
. This is the first component markup we’re covering that shows Blazor event handling. When the user clicks on the button, the OnSpeakButtonClickAsync
event handler is called.
You can specify event handlers for all valid Document Object Model (DOM) events. The syntax follows a very specific pattern @on{EventName}={EventHandler}
. This syntax is applied as an element attribute, where:
-
The
{EventName}
is the DOM event name. -
The
{EventHandler}
is the name of the method that will handle the event.
For example, @onclick=OnSpeakButtonClickAsync
assigns the OnSpeakButtonClickAsync
event handler to the click
event of the element, in other words when the click is fired, it calls OnSpeakButtonClickAsync
.
The OnSpeakButtonClickAsync
method is defined in the component shadow, and it is Task
returning. This means that in addition to synchronous event handlers, asynchronous event handlers are fully supported. With Blazor event handlers, changes to the UI are automatically triggered, thus you will not have to manually call StateHasChanged
to signal re-rendering. The AdditiveSpeechComponent.razor.cs C# file looks like this:
namespace
Learning.Blazor.Components
{
public
partial
class
AdditiveSpeechComponent
{
private
bool
_isSpeaking
=
false
;
private
string
_dynamicCSS
{
get
{
return
string
.
Join
(
" "
,
GetStyles
(
)
)
.
Trim
(
)
;
IEnumerable
<
string
>
GetStyles
(
)
{
if
(
string
.
IsNullOrWhiteSpace
(
Message
)
)
yield
return
"is-hidden"
;
if
(
_isSpeaking
)
yield
return
"is-flashing"
;
}
;
}
}
[Parameter]
public
string?
Message
{
get
;
set
;
}
=
null
!
;
async
Task
OnSpeakButtonClickAsync
(
)
{
if
(
Message
is
null
or
{
Length
:
0
}
)
{
return
;
}
var
(
voice
,
voiceSpeed
)
=
AppState
.
ClientVoicePreference
;
var
bcp47Tag
=
Culture
.
CurrentCulture
.
Name
;
_isSpeaking
=
true
;
await
JavaScript
.
SpeakMessageAsync
(
this
,
nameof
(
OnSpokenAsync
)
,
Message
,
voice
,
voiceSpeed
,
bcp47Tag
)
;
}
[JSInvokable]
public
Task
OnSpokenAsync
(
double
elapsedTimeInMilliseconds
)
=
>
InvokeAsync
(
(
)
=
>
{
_isSpeaking
=
false
;
Logger
.
LogInformation
(
"Spoke utterance in {ElapsedTime} milliseconds"
,
elapsedTimeInMilliseconds
)
;
StateHasChanged
(
)
;
}
)
;
}
}
The
AdditiveSpeechComponent
maintains several bits of component state.The
OnSpeakButtonClickAsync
method conditionally speaks a message.The
OnSpokenAsync
method is called after the message has been spoken.
The class has an _isSpeaking
field, that defaults to false
. This value is used to determine how to render the <button>
. The _dynamicCSS
property only has a get
accessor, which makes it read-only. It determines the styles applied to the <button>
. The Message
property is a Parameter
, which is what allows it to be assigned from consuming components.
The event handler that was assigned to handle the button’s click
event is the OnSpeakButtonClickAsync
method. When there is a meaningful value from the Message
, this handler gets the voice
and voiceSpeed
from the in-memory app state service, as well as the Best Current Practices (BCP 47) language tag value from the current culture. The _isSpeaking
bit is set to true
, and a call to JavaScript.SpeakMessageAsync
is awaited given this
component, the name of the OnSpokenAsync
callback, the Message
, voice
, voiceSpeed
, and bcp47Tag
. This pattern might start looking a bit familiar, as much or as little as your app needs to rely on native functionality from the browser it can use JavaScript interop.
The OnSpokenAsync
method is declared as JSInvokable
. Since this callback happens asynchronously and at an undetermined time, the component couldn’t know when to re-render, so you must tell it to with StateHasChanged
.
Tip
Anytime you define a method that is JSInvokable
that alters the state of the component, you must call StateHasChanged
to signal a re-render.
The OnSpokenAsync
handler is expressed as InvokeAsync
, which executes the given work item on the renders synchronization context. It sets _isSpeaking
to false
, logs the total amount of time the message was spoken and then it notifies the component that its state has changed.
The markup is minimal and the code behind is clean but very powerful. Let’s lean into the JSRuntimeExtensions.cs C# file to see what the SpeakMessageAsync
looks like:
namespace
Learning.Blazor.Extensions
;
internal
static
partial
class
JSRuntimeExtensions
{
internal
static
ValueTask
SpeakMessageAsync
<
T
>(
this
IJSRuntime
jsRuntime
,
T
dotnetObj
,
string
callbackMethodName
,
string
message
,
string
defaultVoice
,
double
voiceSpeed
,
string
lang
)
where
T
:
class
=>
jsRuntime
.
InvokeVoidAsync
(
"app.speak"
,
DotNetObjectReference
.
Create
(
dotnetObj
),
callbackMethodName
,
message
,
defaultVoice
,
voiceSpeed
,
lang
);
}
Extending the IJSRuntime
functionality with meaningful names makes me happy. I find joy in these small victories, but it does make for a more enjoyable experience when reading the code. Being able to read it as JavaScript.SpeakMessageAsync
is very self-descriptive. This extension method delegates to the IJSRuntime.InvokeVoidAsync
method, calling "app.speak"
given the DotNetObjectReference
, the callback method name, a message
, voice
, voice speed, and language. I could have called InvokeVoidAsync
directly from the component, but I instead prefer the descriptive method name of the extension method. This is the pattern that I recommend, as it helps to encapsulate the logic and it’s easier to consume from multiple call points. The JavaScript code that this extension method relies on is part of the wwwroot/js/app.js file:
const
cancelPendingSpeech
=
(
)
=>
{
if
(
window
.
speechSynthesis
&&
window
.
speechSynthesis
.
pending
===
true
)
{
window
.
speechSynthesis
.
cancel
(
)
;
}
}
;
const
speak
=
(
dotnetObj
,
callbackMethodName
,
message
,
defaultVoice
,
voiceSpeed
,
lang
)
=>
{
const
utterance
=
new
SpeechSynthesisUtterance
(
message
)
;
utterance
.
onend
=
e
=>
{
if
(
dotnetObj
)
{
dotnetObj
.
invokeMethodAsync
(
callbackMethodName
,
e
.
elapsedTime
)
}
}
;
const
voices
=
window
.
speechSynthesis
.
getVoices
(
)
;
try
{
utterance
.
voice
=
!
!
defaultVoice
&&
defaultVoice
!==
'Auto'
?
voices
.
find
(
v
=>
v
.
name
===
defaultVoice
)
:
voices
.
find
(
v
=>
!
!
lang
&&
v
.
lang
.
startsWith
(
lang
)
)
||
voices
[
0
]
;
}
catch
{
}
utterance
.
volume
=
1
;
utterance
.
rate
=
voiceSpeed
||
1
;
window
.
speechSynthesis
.
speak
(
utterance
)
;
}
;
window
.
app
=
Object
.
assign
(
{
}
,
window
.
app
,
{
speak
,
// omitted for brevity...
}
)
;
// Prevent the client from speaking when the user closes the tab or window.
window
.
addEventListener
(
'beforeunload'
,
_
=>
{
cancelPendingSpeech
(
)
;
}
)
;
As a safety net to avoid the browser from speaking when the user closes the tab or window, the
cancelPendingSpeech
method is defined.The
speak
function creates and prepares anutterance
instance for usage.The
utterance.voice
property is set to thevoices
array, filtered by thedefaultVoice
andlang
parameters.The
utterance
is passed to thespeechSynthesis.speak
method.The
beforeunload
event handler is defined to cancel any pending speech.
The cancelPendingSpeech
function checks if the window.speechSynthesis
object is truthy (in this case meaning it’s not null
or undefined
). If there are any pending utterances in the queue, a call to window.speechSynthesis.cancel()
is made — removing all utterances from the queue.
The "app.speak"
method is defined as the function named speak
. It has six parameters, which feels like too many. You could choose to parameterize this with a single top-level object if you’d like, but that would require a new model and additional serialization. I’d probably limit a parameter list to no more than six, but as with everything in programming, there are tradeoffs. The speak
method body starts by instantiating a SpeechSynthesisUtterance
given the message
. This object exposes an end/onend
event that is fired when the utterance has finished being spoken. An inline event handler is assigned, which relies on the given dotnetObj
instance and the callbackMethodName
. When the utterance is done being spoken, the event fires and calls back onto the calling components given method.
An attempt to assign the desired voice to speak the utterance is made. This can be problematic, and error-prone — as such its attempt is fragile and protected with a try / catch
. If it works, great, if not, it’s not a big deal as the browser will select the default voice. The volume is to 1
and the speed at which the utterance is spoken is set as well.
With an utterance
instance prepared, a call to window.speechSynthesis.speak(utterance)
is made. This will enqueue the utterance into the native speech synthesis queue. When the utterance
reaches the end of the queue, it is spoken. The "app.speak"
name comes from how the speak
function const
is added to either a new instance of app
or the existing one.
If a long utterance is being spoken, and the user closes the app’s browser tab or window, but leaves the browser open — the utterance will continue to be spoken. To avoid this behavior, we’ll call cancelPendingSpeech
when the window is unloaded.
The AdditiveSpeechComponent
could be bundled into a separate Razor component project, and distributed to consuming apps. That approach is very beneficial as it exposes functionality and shares it with consumers. All of the functionality of this component is encapsulated and could benefit from being shared via NuGet. At the time of writing, the component remained as part of the Web.Client project but that’s not to say that this couldn’t easily evolve in complexity, or add new functionality. Once on NuGet it could be used by other .NET developers who consume open-source projects.
The Learning Blazor sample app demonstrates how to create Razor projects and consume them from the Blazor web client. The Web.Client project depends on the Web.TwitterComponents Razor class library. The Web.TwitterComponents project encapsulates a few Twitter-specific components. The Web.Client consumes these components and exposes them to the Blazor web client.
Sharing And Consuming Custom Components
To consume a component, you reference it from a consuming component’s markup. Blazor provides many components out-of-the-box, from layouts to navigation, from standard form controls to error boundaries, from page titles to head outlets, and so on. See, ASP.NET Core built-in Razor components for a listing of the available components.
When the built-in components are not enough, you can turn to custom components. There are many other vendor-provided components. Additionally, there is a massive open-source community that builds component libraries as well. Chances are you’ll find what you need as a developer when building Blazor apps from all the vendor-provided component libraries out there, consider the following list of vendor resources:
There is a community-curated list on GitHub known as Awesome Blazor which is another great resource. Sometimes, you may require functionality that isn’t available from the framework, from vendors, or even from the community at large. When this happens, you can write your component libraries.
Since Blazor is built atop Razor, all of the components are Razor components. They’re easily identifiable by their .razor file extension.
Chrome: The overloaded term
With graphical user interface (GUI) apps, there is an old term that’s been overloaded through the years. The term “chrome” refers to an element of the user interface that displays the various commands or capabilities available to the user. For example, the chrome of the Learning Blazor sample app is the top bar. This contains the app’s top-level navigation, theme display icon, and the buttons for various popup modal components such as the notification toggle, task list toggle, and the log in/out button. This was shown in The header and footer components figures from chapter two. When I refer to chrome, I’m not talking about the web browser here. We’ve already discussed navigation and routing a bit, so let’s focus on modal modularity.
Modal Modularity And Blazor Component Hierarchies
Most apps need to interact with the user and prompt them for input. The app’s navigation is a user experience and one said example of user input — is the user clicks a link to a route they want to visit, then the app takes an action. Sometimes we’ll need to prompt the user to use the keyboard, instead of the mouse. The questions we ask users vary primarily by domain, for example; “What’s your email address?” or “What’s your message to send?” Answers vary by control type, meaning free-form text line or text area, or a checkbox, or select list, or a button. All of this is fully supported with Blazor, you can subscribe to native HTML element events and handle them in Razor C# component logic. There are native forms of integration and modal/input binding validation, templating, and component hierarchies.
One such control is a custom control, named ModalComponent
. This component is going to be used throughout the app for various use cases. It’s will have an inherited component to exemplify component subclass patterns, which are common in C#, but were under-utilized as a programming pattern for JavaScript SPAs. Consider the ModalComponent.razor markup file:
<
div
class
=
"modal has-text-left @_isActiveClass"
>
<
div
class
=
"modal-background"
@
onclick
=
@CancelAsync
>
<
/
div
>
<
div
class
=
"modal-card"
>
<
header
class
=
"modal-card-head"
>
<
p
class
=
"modal-card-title"
>
@TitleContent
<
/
p
>
<
button
class
=
"delete"
aria-label
=
"close"
@
onclick
=
@CancelAsync
>
<
/
button
>
<
/
header
>
<
section
class
=
"modal-card-body"
>
@BodyContent
<
/
section
>
<
footer
class
=
"modal-card-foot is-justify-content-flex-end"
>
<
div
>
@ButtonContent
<
/
div
>
<
/
footer
>
<
/
div
>
<
/
div
>
The outermost element is a
div
with themodal
class.The title is represented as a
header
element with themodal-card-title
class.The body is a
section
with themodal-card-body
class.The
footer
is styled with themodal-card-foot
class.
The HTML is a modal styled div
with an _isActiveClass
value bound to the modal’s class
attribute. Meaning that the state of the modal, whether it is active (shown) or not is dependent on a component variable. It has a background style that applies an overlay, making this element popup as a modal dialog displayed to the user. The background div
element itself handles user clicks by calling CancelAsync
and covers the entire page.
The HTML is semantically accurate; representing an industry-standardized three-part header/body/footer layout. The first template placeholder is the @TitleContent
. This is a required RenderFragment
which allows for the consuming component to provide custom title markup. The header
also contains a button
which will call CancelAsync
when clicked.
The BodyContent
is styled appropriately as a modal’s body, which is a section
HTML element and semantically positioned beneath the header
and above the footer
.
The modal footer
contains the required ButtonContent
markup. Collectively, this modal represents a common dialog component where consumers can plug in their customized markup and corresponding prompts.
The component shadow defines the component’s parameter properties, events, component state, and functionality. Consider the ModalComponent.razor.cs C# file:
namespace
Learning.Blazor.Components
;
public
partial
class
ModalComponent
{
private
string
_isActiveClass
=
>
IsActive
?
"is-active"
:
""
;
[Parameter]
public
EventCallback
<
DismissalReason
>
Dismissed
{
get
;
set
;
}
[Parameter]
public
bool
IsActive
{
get
;
set
;
}
[Parameter, EditorRequired]
public
RenderFragment
TitleContent
{
get
;
set
;
}
=
null
!
;
[Parameter, EditorRequired]
public
RenderFragment
BodyContent
{
get
;
set
;
}
=
null
!
;
[Parameter, EditorRequired]
public
RenderFragment
ButtonContent
{
get
;
set
;
}
=
null
!
;
/// <summary>
/// Gets the reason that the <see cref="ModalComponent"/> was dismissed.
/// </summary>
public
DismissalReason
Reason
{
get
;
private
set
;
}
/// <summary>
/// Sets the <see cref="ModalComponent"/> instance's
/// <see cref="IsActive"/> value to <c>true</c> and
/// <see cref="Reason"/> value as <c>default</c>.
/// It then signals for a change of state, this rerender will
/// show the modal.
/// </summary>
public
Task
ShowAsync
(
)
=
>
InvokeAsync
(
(
)
=
>
(
IsActive
,
Reason
)
=
(
true
,
default
)
)
;
/// <summary>
/// Sets the <see cref="ModalComponent"/> instance's
/// <see cref="IsActive"/> value to <c>false</c> and
/// <see cref="Reason"/> value as given <paramref name="reason"/>
/// value. It then signals for a change of state,
/// this rerender will cause the modal to be dismissed.
/// </summary>
public
Task
DismissAsync
(
DismissalReason
reason
)
=
>
InvokeAsync
(
async
(
)
=
>
{
(
IsActive
,
Reason
)
=
(
false
,
reason
)
;
if
(
Dismissed
.
HasDelegate
)
{
await
Dismissed
.
InvokeAsync
(
Reason
)
;
}
}
)
;
/// <summary>
/// Dismisses the shown modal, the <see cref="Reason"/>
/// will be set to <see cref="DismissalReason.Confirmed"/>.
/// </summary>
public
Task
ConfirmAsync
(
)
=
>
DismissAsync
(
DismissalReason
.
Confirmed
)
;
/// <summary>
/// Dismisses the shown modal, the <see cref="Reason"/>
/// will be set to <see cref="DismissalReason.Cancelled"/>.
/// </summary>
public
Task
CancelAsync
(
)
=
>
DismissAsync
(
DismissalReason
.
Cancelled
)
;
/// <summary>
/// Dismisses the shown modal, the <see cref="Reason"/>
/// will be set to <see cref="DismissalReason.Verified"/>.
/// </summary>
public
Task
VerifyAsync
(
)
=
>
DismissAsync
(
DismissalReason
.
Verified
)
;
}
public
enum
DismissalReason
{
Unknown
,
Confirmed
,
Cancelled
,
Verified
}
;
The
ModalComponent
class is part of theLearning.Blazor.Components
namespace.Several properties together, represent examples of required component parameters, events, templates, and component state values.
As for the functionality and modularity, the modal component can be shown, and just as easily dismissed.
The
enum DismissalReason
type is defined within the same file-scoped namespace.
Tip
In Blazor when you define a property that is used as a Parameter
and you want that parameter to be required, you can use the framework-provided EditorRequired
attribute. This specifies that the component parameter is required to be provided by the user when authoring it in the editor. If a value for this parameter is not provided, editors or build tools may provide warnings indicating the user to specify a value.
The ModalComponent
class defines several properties:
-
_isActiveClass
: Aprivate string
which serves as a computed property, which evaluates theIsActive
property and returns"is-active"
whentrue
. This was bound to the modal’s markup, where thediv
’sclass
attribute had some static classes and a dynamically bound value. -
Dismissed
: A component parameter, which is of typeEventCallback<DismissalReason>
. An event callback accepts delegate assignments from consumers, where events flow from this component to interested recipients. -
IsActive
: Abool
value, which represents the current state of whether the modal is actively being displayed to the user. This parameter is not required, and is typically set implicitly from calls toDismissAsync
. -
Three
RenderFragment
template placeholder objects, for the header title, body content, and footer controls which are buttons;TitleContent
,BodyContent
, andButtonContent
. -
Reason
: The reason for the dismissal of the modal, is either “unknown”, “confirmed”, “canceled”, or “verified”.
The ModalComponent
exposes modularity as the functionality is templated, and consumers have hooks into the component. Consumers can call any of these public Task
returning asynchronous operational methods:
-
ShowAsync
: Immediately shows the modal to the user. This method is expressed as a call toInvokeAsync
given a lambda expression which sets the values ofIsActive
totrue
and assignsdefault
to theReason
(orDismissalReason.Unknown
). CallingStateHasChanged
is unnecessary at this point. Asynchronous operational support will automatically re-render the UI components implicitly as needed. -
DismissAsync
: Given a dismissal reason, immediately dismisses the modal. TheIsActive
state is set tofalse
which will effectively hide the component from the user. -
ConfirmAsync
: Sets the dismissal reason asConfirmed
, delegates toDismissAsync
. -
CancelAsync
: Sets the dismissal reason asCancelled
, delegates toDismissAsync
. -
VerifyAsync
: Sets the dismissal reason asVerified
, delegates toDismissAsync
.
The enum DismissalReason
type defines four states, Unknown
(which is the default), Confirmed
, Cancelled
(can occur implicitly from the user clicking outside the modal), and Verified
. While I will usually place every type definition in its file, I choose to keep the enum DismissalReason
within the same file. To me, these are logically cohesive and belong together.
Exploring Blazor Event Binding
The ModalComponent
is consumed by the VerificationModalComponent
, let’s take a look at how this is achieved in the VerificationModalComponent.razor markup file:
@inherits LocalizableComponentBase
<
VerificationModalComponent
>
<
ModalComponent
@
ref
=
"_modal"
Dismissed
=
@OnDismissed
>
<
TitleContent
>
<
span
class
=
"icon pr-2"
>
<
i
class
=
"fas fa-robot"
>
<
/
i
>
<
/
span
>
<
span
>
@Localizer["AreYouHuman"]
<
/
span
>
<
/
TitleContent
>
<
BodyContent
>
<
form
>
<
div
class
=
"field"
>
<
label
class
=
"label"
>
@_math.HumanizeQuestion()
<
/
label
>
<
div
class
=
"field-body"
>
<
div
class
=
"field"
>
<
p
class
=
"control is-expanded has-icons-left"
>
@{ var inputValidityClass = _answeredCorrectly is false ? " invalid" : ""; var inputClasses = $"input{inputValidityClass}"; }
<
input
@
bind
=
"_attemptedAnswer"
class
=
@inputClasses
type
=
"text"
placeholder
=
"@Localizer["
AnswerFormat
"
,
_math
.
GetQuestion
(
)
]
"
/
>
<
span
class
=
"icon is-small is-left"
>
<
i
class
=
"fas fa-info-circle"
>
<
/
i
>
<
/
span
>
<
/
p
>
<
/
div
>
<
/
div
>
<
/
div
>
<
/
form
>
<
/
BodyContent
>
<
ButtonContent
>
<
button
class
=
"button is-info is-large is-pulled-left"
@
onclick
=
Refresh
>
<
span
class
=
"icon"
>
<
i
class
=
"fas fa-redo"
>
<
/
i
>
<
/
span
>
<
span
>
@Localizer["Refresh"]
<
/
span
>
<
/
button
>
<
button
class
=
"button is-success is-large"
@
onclick
=
AttemptToVerify
>
<
span
class
=
"icon"
>
<
i
class
=
"fas fa-check"
>
<
/
i
>
<
/
span
>
<
span
>
@Localizer["Verify"]
<
/
span
>
<
/
button
>
<
/
ButtonContent
>
<
/
ModalComponent
>
The
_modal
reference wires theOnDismissed
event handler.The
TitleContent
renders a localized prompt message and a robot icon.The
BodyContent
renders a form with a single input field.The
_attemptedAnswer
property is bound to the input field’svalue
attribute.The buttons are rendered in the
ButtonContent
template.
The VerificationModalComponent
markup relies on the ModalComponent
, and it captures a reference to the modal using the @ref="_modal"
syntax. Blazor will automatically assign the _modal
field from the instance value of the referenced component markup. Internal to the VerificationModalComponent
the dependent ModalComponent.Dismissed
event is handled by the OnDismissed
handler. In other words, the ModalComponent.Dismissed
is a required parameter and it’s an event that the component will fire. The VerificationModalComponent.OnDismissed
event handler is assigned to handle it. This is custom event binding, where the consuming component handles the dependent component’s exposed parameterized event.
The verification modal’s title content (TitleContent
) prompts the user with an “Are you human?” message.
The BodyContent
markup contains a native HTML form
element. Within this markup is a simple label
and corresponding text input
element. The label splats a question into the markup from the evaluated _math.GetQuestion()
invocation (more on the _math
object in a bit). The attempted answer input
element has dynamic CSS classes bound to it, based on whether the question was correctly answered.
The input
element has its value
bound to the _attemptedAnswer
variable. It also has a placeholder
bound from a localized answer format given the math question, which will serve as a clue to the user about what’s expected.
The ButtonContent
markup has two buttons, one for refreshing the question (via the Refresh
method), and the other for attempting to verify the answer (via the AttemptToVerify
method). This is an example of native event binding, where the button
elements have their click
events bound to the corresponding event handlers.
The ModalComponent
itself is a base modal, while the VerificationModalComponent
uses the base modal and employs a very specific verification prompt. The VerificationModalComponent
will render as shown in Figure 4-3.
The component shadow for the VerificationModalComponent
resides in the VerificationModalComponent.cs file:
namespace
Learning.Blazor.Components
{
public
sealed
partial
class
VerificationModalComponent
{
private
AreYouHumanMath
_math
=
AreYouHumanMath
.
CreateNew
(
)
;
private
ModalComponent
_modal
=
null
!
;
private
bool?
_answeredCorrectly
=
null
!
;
private
string?
_attemptedAnswer
=
null
!
;
private
object?
_state
=
null
;
[Parameter, EditorRequired]
public
EventCallback
<
(
bool
IsVerified
,
object?
State
)
>
OnVerificationAttempted
{
get
;
set
;
}
public
Task
PromptAsync
(
object?
state
=
null
)
{
_state
=
state
;
return
_modal
.
ShowAsync
(
)
;
}
private
void
Refresh
(
)
=
>
(
_math
,
_attemptedAnswer
)
=
(
AreYouHumanMath
.
CreateNew
(
)
,
null
)
;
private
async
Task
OnDismissed
(
DismissalReason
reason
)
{
if
(
OnVerificationAttempted
.
HasDelegate
)
{
await
OnVerificationAttempted
.
InvokeAsync
(
(
reason
is
DismissalReason
.
Verified
,
_state
)
)
;
}
}
private
async
Task
AttemptToVerify
(
)
{
if
(
int
.
TryParse
(
_attemptedAnswer
,
out
var
attemptedAnswer
)
)
{
_answeredCorrectly
=
_math
.
IsCorrect
(
attemptedAnswer
)
;
if
(
_answeredCorrectly
is
true
)
{
await
_modal
.
DismissAsync
(
DismissalReason
.
Verified
)
;
}
}
else
{
_answeredCorrectly
=
false
;
}
}
}
}
The
VerificationModalComponent
wraps theModalComponent
to add a verification layer.An event callback exposes whether the verification attempt was successful.
The prompt method delegates to the
ModalComponent.ShowAsync
method.The
Refresh
method resets the_math
and_attemptedAnswer
fields.The
OnDismissed
event handler is invoked when the modal is dismissed.The
AttemptToVerify
method dismisses the modal if the answer is correct.
The VerificationModalComponent
class defines the following fields:
-
_math
: The math object is of typeAreYouHumanMath
and is assigned from theAreYouHumanMath.CreateNew()
factory method. This is a custom type that helps to represent a simple mathematical problem that a human could likely figure out in their head. -
_modal
: The field representing theModalComponent
instance from the corresponding markup. Methods will be called on this instance, such asShowAsync
to display the modal to the user. -
_answeredCorrectly
: The three-statebool
is used to determine if the user answered the question correctly. -
_attemptedAnswer
: The nullablestring
bound to theinput
element, used to store the user-entered value. -
_state
: A state object that represents an opaque value, stored on behalf of the consumer. When the consuming component callsPromptAsync
, if they passstate
it’s assigned to the_state
variable then given back to the caller when theOnVerificationAttempted
event callback is invoked.
The OnVerificationAttempted
is a required parameter. The callback signature passes a tuple object, where its first value represents whether the verification attempt was successful. This is true
when the user correctly entered the correct answer, otherwise false
. The second value is an optional state object.
The PromptAsync
method is used to display the modal dialog and accepts an optional state object.
The Refresh
method is bound to the refresh button and is called to re-randomize the question being asked. The AreYouHumanMath.CreateNew()
factory method is reassigned to the _math
field and the _attemptedAnswer
is set to null
.
The OnDismissed
method is the handler for the ModalComponent.Dismissed
event callback. When the base modal is dismissed, it will have DismissalReason
. With the reason
and when the OnVerificationAttempted
has a delegate, it’s invoked passing whether it’s verified and any state that was held on to when prompted.
The AttemptToVerify
method is bound to the verify button. When called it will attempt to parse the _attemptedAnswer
as an int
and ask the _math
object if the answer is correct. When true
, the _modal
is dismissed as Verified
. This will indirectly call Dismissed
.
I bet you’re wondering what the AreYouHumanMath
object looks like, it sure was fun writing this cute little object. Take a look at the AreYouHumanMath.cs C# file:
namespace
Learning.Blazor.Models
;
public
readonly
record
struct
AreYouHumanMath
(
byte
LeftOperand
,
byte
RightOperand
,
MathOperator
Operator
=
MathOperator
.
Addition
)
{
private
static
readonly
Random
s_random
=
Random
.
Shared
;
/// <summary>
/// Determines if the given <paramref name="guess"/> value is correct.
/// </summary>
/// <param name="guess">The value being evaluated for correctness.</param>
/// <returns>
/// <c>true</c> when the given <paramref name="guess"/> is correct,
/// otherwise <c>false</c>.
/// </returns>
/// <exception cref="ArgumentException">
/// An <see cref="ArgumentException"/> is thrown when
/// the current <see cref="Operator"/> value is not defined.
/// </exception>
public
bool
IsCorrect
(
int
guess
)
=
>
guess
=
=
Operator
switch
{
MathOperator
.
Addition
=
>
LeftOperand
+
RightOperand
,
MathOperator
.
Subtraction
=
>
LeftOperand
-
RightOperand
,
MathOperator
.
Multiplication
=
>
LeftOperand
*
RightOperand
,
_
=
>
throw
new
ArgumentException
(
$
"The operator is not supported: {Operator}"
)
}
;
/// <summary>
/// The string representation of the <see cref="AreYouHumanMath"/> instance.
/// <code language="cs">
/// <![CDATA[
/// var math = new AreYouHumanMath(7, 3);
/// math.ToString(); // "7 + 3 ="
/// ]]>
/// </code>
/// </summary>
/// <exception cref="ArgumentException">
/// An <see cref="ArgumentException"/> is thrown when
/// the current <see cref="Operator"/> value is not defined.
/// </exception>
public
override
string
ToString
(
)
{
var
operatorStr
=
Operator
switch
{
MathOperator
.
Addition
=
>
"+"
,
MathOperator
.
Subtraction
=
>
"-"
,
MathOperator
.
Multiplication
=
>
"*"
,
_
=
>
throw
new
ArgumentException
(
$
"The operator is not supported: {Operator}"
)
}
;
return
$
"{LeftOperand} {operatorStr} {RightOperand} ="
;
}
public
string
GetQuestion
(
)
=
>
$
"{this} ?"
;
public
static
AreYouHumanMath
CreateNew
(
MathOperator
?
mathOperator
=
null
)
{
var
mathOp
=
mathOperator
.
GetValueOrDefault
(
RandomOperator
(
)
)
;
var
(
left
,
right
)
=
mathOp
switch
{
MathOperator
.
Addition
=
>
(
Next
(
)
,
Next
(
)
)
,
MathOperator
.
Subtraction
=
>
(
Next
(
120
)
,
Next
(
120
)
)
,
_
=
>
(
Next
(
30
)
,
Next
(
30
)
)
,
}
;
(
left
,
right
)
=
(
Math
.
Max
(
left
,
right
)
,
Math
.
Min
(
left
,
right
)
)
;
return
new
AreYouHumanMath
(
(
byte
)
left
,
(
byte
)
right
,
mathOp
)
;
static
MathOperator
RandomOperator
(
)
{
var
values
=
Enum
.
GetValues
<
MathOperator
>
(
)
;
return
values
[
s_random
.
Next
(
values
.
Length
)
]
;
}
;
static
int
Next
(
byte?
maxValue
=
null
)
=
>
s_random
.
Next
(
1
,
maxValue
?
?
byte
.
MaxValue
)
;
}
}
public
enum
MathOperator
{
Addition
,
Subtraction
,
Multiplication
}
;
The
AreYouHumanMath
is a positionalrecord
that defines a simple math problem.The ability to test whether a
guess
is the correct answer is expressed by theIsCorrect
method.The
ToString
method is used to display the math problem.The
CreateNew
method is used to create a new random math problem.The
MathOperator
enum defines whether a problem is either addition, subtraction, or multiplication.
The AreYouHumanMath
object is a readonly record struct
. As such, it’s immutable but allows for with
expressions which creates a clone. It’s a positional record, meaning it can only be instantiated using the required parameter constructor. A left
and right
operand value is required, but the math operator is optional and defaults to addition.
The Random.Shared
was introduced with .NET 6, and is used to assign the static readonly Random
instance.
The IsCorrect
method accepts a guess
. This method will return true
only when the given guess
equals the evaluated math operation of the left and right operand values. For example, new AreYouHumanMath(7, 3).IsCorrect(10)
would evaluate as true
because seven plus three equals ten. This method is expressed as a switch expression on the Operator
. Each operator case arm is expressed as the corresponding math operation.
The ToString
and GetQuestion
methods return the mathematical representation of the applied operator and two operands. For example, new AreYouHumanMath(7, 3).ToString()
would evaluate as "7 + 3 ="
, whereas new AreYouHumanMath(7, 3).GetQuestion()
would be "7 + 3 = ?"
.
The CreateNew
method relies heavily on the Random
class to help ensure that each time it’s invoked a new question is asked. When the optional mathOperator
is provided it’s used, otherwise, a random one is determined. With an operator the operands are randomly determined, the maximum number is the left operand and the minimum is the right.
As for the enum MathOperator
, I intentionally decided to avoid division. With the use of random numbers, it would have been a bit more complex, with concerns of dividing by 0
and precision. Instead, I was hoping for math that you could more than likely do in your head.
The VerificationModalComponent
is used as a spam blocker on the Contact.razor page that we’ll discuss in detail. We’ll look at that pattern in Chapter 8. The ModalComponent
is also used by the AudioDescriptionComponent
and the LanguageSelectionComponent
. These two components are immediately to the right of the ThemeIndicatorComponent
discussed in “Native Theme Awareness”.
Summary
You learned a lot more about how extensive and configurable Blazor app development is. You have a much better understanding of how to authenticate a user in the context of a Blazor WebAssembly application. I showed you a familiar web client startup configuration pattern where all the client-side services are registered. We customized the authorization user experience. We explored the implementation of browser native speech synthesis. Finally, we read all the markup and C# code for the chrome within the app’s header, and modal dialog hierarchical capabilities. We now have a much better understanding of Blazor event management, firing, and consuming.
In the next chapter, I’m going to show you a pattern for localizing the app in forty different languages. I’ll show you how we use an entirely free GitHub Action combined with Azure Cognitive Services to machine translate resource files on our behalf. You’ll learn exactly how to implement localization, using the framework-provided IStringLocalizer<T>
type along with static resource files. You’ll learn various formatting details as well.
Get Learning Blazor now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.