Migrating from Radzen
Most of a Radzen app maps one to one: buttons, data grids and dialogs have IzzyBlazor counterparts, and much of what you built by hand around them (fields, tabs, menus, a side panel, a diff) is a component here. This page shows the Radzen code beside the IzzyBlazor code, what's renamed, and what behaves differently.
At a glance
| Radzen, or built by hand | IzzyBlazor |
|---|---|
RadzenButton | Button, IconButton |
RadzenDataGrid | Table, with PropertyColumn and TemplateColumn |
DialogService | DialogService, with DialogHost in the layout |
<RadzenComponents /> | <DialogHost />, and <ToastRegion /> for toasts |
<input>, <textarea>, <select> | TextField, TextArea, Select, Combobox, NumberField |
<input type="date">, "datetime-local" | DateField |
| A list paged by cursor | Table with Paging |
| A line diff | Diff |
<main> and <aside> | AppShell, with an AppAside in the page |
A nav of links as tabs | Tabs with TabLink |
| A dropdown menu | Menu |
RadzenDropZoneContainer, RadzenDropZone | DropZoneGroup, with a ListView per zone |
The two libraries can run side by side while you move page by page, as in
Add to an existing app. Some types have the same name in
both: DialogService, DialogOptions, ConfirmOptions, Dialog,
Tooltip, TextAlign, Orientation, IconStyle,
SidebarPosition and ColorScheme. A file with @using for both gets
ambiguous references, so move a file to IzzyBlazor whole, or qualify the name
(IzzyBlazor.DialogService). Components of your own can clash too, such as a Menu:
rename yours, or replace it with IzzyBlazor's.
Setup
Replace Radzen's services, stylesheet, script and <RadzenComponents />. IzzyBlazor's theme is
<IzzyTheme /> in the head, and there's no script tag: Blazor loads IzzyBlazor's script
itself. DialogHost is where DialogService shows its dialogs, and it must be
interactive. When Routes is interactive, put it in the layout as it is; otherwise give it a render
mode: <DialogHost @rendermode="InteractiveServer" />.
// Program.cs
builder.Services.AddRadzenComponents();
<!-- App.razor -->
<link rel="stylesheet" href=
"_content/Radzen.Blazor/css/standard-base.css"
/>
...
<script src="_framework/blazor.web.js"></script>
<script src=
"_content/Radzen.Blazor/Radzen.Blazor.js">
</script>
<!-- MainLayout.razor -->
<RadzenComponents />// Program.cs
builder.Services.AddIzzyBlazor();
<!-- App.razor -->
<link rel="stylesheet" href=
"@Assets["_content/IzzyBlazor.Core/izzyblazor.css"]"
/>
<IzzyTheme />
...
<script src="_framework/blazor.web.js"></script>
<!-- MainLayout.razor -->
<DialogHost />
Replace @using Radzen and @using Radzen.Blazor in _Imports.razor with
@using IzzyBlazor, and remove the stylesheets you wrote to adjust Radzen's rz- classes.
The rest of setup, such as your brand color in IzzyTheme, is in
Blazor Web App and
Themes and presets.
Buttons
RadzenButton becomes Button, with its text as content. A button with only an icon is an
IconButton, which needs a Label for screen readers; icons are Lucide's
(Lucide.X), not Material names. Radzen's rz-button classes on a link become a
Button with Href, and on a submit button a Button with
Submit.
<RadzenButton id="key-create" Text="Create key"
Click="CreateAsync" Disabled="_saving" />
<RadzenButton Text="Cancel"
ButtonStyle="ButtonStyle.Base"
Variant="Variant.Outlined"
Click="Cancel" />
<RadzenButton Text="Remove"
Size="ButtonSize.Small"
ButtonStyle="ButtonStyle.Danger"
Variant="Variant.Text"
Click="(() => RemoveAsync(member))" />
<RadzenButton Icon="close"
Size="ButtonSize.Small"
ButtonStyle="ButtonStyle.Base"
Variant="Variant.Text"
aria-label="Remove event"
Click="(() => Remove(at))" />
<a class="rz-button rz-primary rz-button-md"
href="connections/new">Add connection</a><Button id="key-create" OnClick="CreateAsync"
Loading="_saving">Create key</Button>
<Button Accent="Accent.Neutral"
Variant="ButtonVariant.Outline"
OnClick="Cancel">Cancel</Button>
<Button Size="ControlSize.Sm"
Accent="Accent.Danger"
Variant="ButtonVariant.Ghost"
OnClick="() => RemoveAsync(member)">
Remove
</Button>
<IconButton Icon="Lucide.X"
Size="ControlSize.Sm"
Accent="Accent.Neutral"
Variant="ButtonVariant.Ghost"
Label="Remove event"
OnClick="() => Remove(at)" />
<Button Href="connections/new">
Add connection
</Button>| RadzenButton | Button |
|---|---|
Text="Save" | Content: <Button>Save</Button> |
Click | OnClick |
ButtonStyle.Primary | Accent.Primary (the default) |
ButtonStyle.Base | Accent.Neutral |
ButtonStyle.Danger | Accent.Danger |
Variant.Filled | ButtonVariant.Solid (the default) |
Variant.Outlined | ButtonVariant.Outline |
Variant.Text | ButtonVariant.Ghost |
ButtonSize.Small | ControlSize.Sm |
Icon="close" with no text | IconButton with Icon="Lucide.X" and a Label |
IsBusy | Loading |
class="rz-button …" on an <a> | Button with Href |
class="rz-button …" on a submit <button> | Button with Submit |
id, aria-label and other attributes go on the button element, as before, so
selectors like #key-create keep working. Loading keeps the button focusable and shows a
spinner, where Disabled alone gives no sign that something is happening. See
Button.
Data grids
RadzenDataGrid becomes Table. A column bound to a property is a
PropertyColumn, taking an expression instead of the property's name; a column with a template is a
TemplateColumn, whose content is the template. There's no Columns wrapper, and
TItem comes from Items.
<RadzenDataGrid TItem="Member" Data="members"
AllowPaging="true" PageSize="50"
AllowSorting="true"
AllowFiltering="true"
FilterMode="FilterMode.Simple"
FilterCaseSensitivity=
"FilterCaseSensitivity.CaseInsensitive"
id="members">
<Columns>
<RadzenDataGridColumn TItem="Member"
Property="Email" Title="Email" />
<RadzenDataGridColumn TItem="Member"
Property="AddedBy" Title="Added by" />
<RadzenDataGridColumn TItem="Member"
Property="AddedAt" Title="Added"
Filterable="false" Width="200px">
<Template Context="member">
ConsoleText.Instant(member.AddedAt)
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="Member"
Title="" Sortable="false"
Filterable="false" Width="130px"
TextAlign="TextAlign.End">
<Template Context="member">
<RadzenButton Text="Remove" ... />
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid><Table Items="members" PageSize="50"
Caption="Members" id="members">
<PropertyColumn Property="m => m.Email"
Sortable Filterable />
<PropertyColumn Property="m => m.AddedBy"
Title="Added by" Sortable Filterable />
<TemplateColumn Title="Added"
Context="member"
SortBy="m => m.AddedAt" SortKey="added"
Sortable Width="Length.Px(200)">
ConsoleText.Instant(member.AddedAt)
</TemplateColumn>
<TemplateColumn Context="member"
Width="Length.Px(130)"
Align="CellAlign.End">
<Button ...>Remove</Button>
</TemplateColumn>
</Table>| RadzenDataGrid | Table |
|---|---|
Data | Items |
TItem | Inferred from Items |
<Columns> | None: columns go straight inside |
RadzenDataGridColumn Property="Email" | PropertyColumn Property="m => m.Email" |
A column with a <Template> | TemplateColumn, with the template as its content |
AllowSorting on the grid | Sortable on each column |
AllowFiltering, FilterMode.Simple | Filterable on each column |
FilterCaseSensitivity.CaseInsensitive | None: text filters always ignore case |
Sortable="false", Filterable="false" | Leave Sortable or Filterable off |
AllowPaging, PageSize="50" | PageSize="50" |
Width="200px" | Width="Length.Px(200)" |
TextAlign="TextAlign.End" | Align="CellAlign.End" |
SelectionMode="DataGridSelectionMode.Multiple" | SelectionMode="SelectionMode.Multiple" (with ItemKey) |
@bind-Value (the selected items) | @bind-Selection (their keys, or all matching); Apply gives the items |
RowSelect, RowDeselect | SelectionChanged |
What behaves differently:
- Sorting, paging and filters go in the address. A sortable header is a link that sets
?sort=email, page numbers set?page=2, and a filter sets?f-email=. The view survives a reload and can be shared, and the back button undoes a sort. Two tables on one page share these keys unless one has aQueryPrefix. To keep the state in the component instead, as Radzen does, bind it:@bind-Sort,@bind-Page,@bind-Filter. - Filters are in the header, not in a row under it. A filterable column has a filter button that
opens its field, and it's marked while in use, with Clear inside.
FilterPaneladds a row below the table listing them all. Text matches anywhere and always ignores case. There's no filter row and no choice of operator. - Selection is a checkbox column, kept by key. Each row has a checkbox (a radio button for
Single) rather than selecting on a row click, and the table keeps the rows'ItemKey, not the items, so a selection survives paging and reloading. The footer counts what's selected, andSelectionActionsgo in a bar above the rows.SelectAllModesays whether the header checkbox takes the page or every row the filters match. In a form the selection posts, so a bulk action is a submit button. Shift-click selects a range. - Columns opt in. Radzen sorts and filters every column unless told not to; a
Tablecolumn sorts or filters only withSortableorFilterable. - A template column sorts by what you say. Radzen sorts a template column by its
Property; aTemplateColumnneedsSortByand aSortKeyfor the address, andFilterByto filter. Captionnames the table for screen readers; it isn't shown. Anidgoes on the table's outer element, so#members trstill finds the rows.
See Table for an ItemsProvider that loads each page
from a server; its DataRequest carries the sort, the range and the filter, and
ToResultAsync applies them to an EF Core query, as LoadDataArgs with Dynamic LINQ did.
Paging by cursor
A list that pages by an opaque "next" token, with no total, can be a Table too. Set
Paging and return the next cursor from the ItemsProvider; the table passes it back as
the request's Cursor. DataPaging.LoadOnScroll adds the next page under the rows as
the last one scrolls into view, in a box as tall as MaxHeight, where a hand-built "Load earlier"
button needed a press; the button stays, for the keyboard.
<table class="rb-table" id="decisions">
foreach (var decision in _decisions)
{
...
}
</table>
if (_next is not null)
{
<RadzenButton id="load-earlier"
Text="Load earlier"
Click="LoadEarlierAsync"
Disabled="_loading" ... />
}
if (_failure is not null)
{
<p role="alert">_failure</p>
}
{
private readonly List<DecisionSummary>
_decisions = [];
private string? _next;
private async Task LoadEarlierAsync()
{
_loading = true;
var page = await Api.SearchAsync(
_query, _next);
if (page.Failed)
{
_failure = "The log couldn't load.";
}
else
{
_decisions.AddRange(page.Items);
_next = page.Next;
}
_loading = false;
}
}<Table @ref="_log" ItemsProvider="LoadAsync"
PageSize="50"
Paging="DataPaging.LoadOnScroll"
MaxHeight="30.Rem"
LoadMoreText="Load earlier"
ItemKey="d => d.Id" Caption="Decisions"
id="decisions">
<TemplateColumn Title="When"
Context="decision">
ConsoleText.Instant(decision.At)
</TemplateColumn>
...
</Table>
{
private Table<DecisionSummary>? _log;
// A failure throws: the table shows it,
// with Retry.
private async
ValueTask<DataResult<DecisionSummary>>
LoadAsync(DataRequest<DecisionSummary> r)
{
var page = await Api.SearchAsync(
_query, r.Cursor);
if (page.Failed)
{
throw new HttpRequestException(
"The log couldn't load.");
}
return new(page.Items)
{
Next = page.Next,
};
}
// A new search starts over.
private Task SearchAsync() =>
_log!.ReloadAsync();
}
The table has an error state: when the provider throws, it shows "Couldn't load the rows." with Retry, and with
Load more the rows already shown stay and the message goes under them. So the provider throws on a failed
request instead of keeping a message of its own. ReloadAsync() loads the list again from where it began, for a new search. With an
ItemKey, a row that's already shown isn't added twice. DataPaging.NextPrevious shows
First, Previous and Next instead, and LoadOnScroll loads as the last row comes into view; see
Paging without a total.
Dialogs
DialogService keeps its name and its OpenAsync<T>(title, parameters, options).
It returns a DialogResult instead of dynamic: Data is what the dialog
closed with, and Canceled is true when it was dismissed. The width is a Length, or one
of the named sizes: Sm is 384px, Md (the default) 512px, Lg 768px and
Xl 1024px. On a phone the dialog still fits the screen.
var parameters = new Dictionary<string, object?>
{
[nameof(KeyDialog.View)] = View,
};
if (await Dialogs.OpenAsync<KeyDialog>(
"Create a key", parameters,
new DialogOptions { Width = "560px" })
is ApiKey created)
{
Show(created);
}var parameters = new Dictionary<string, object?>
{
[nameof(KeyDialog.View)] = View,
};
var result = await Dialogs.OpenAsync<KeyDialog>(
"Create a key", parameters,
new DialogOptions { Width = Length.Px(560) });
if (result.Data is ApiKey created)
{
Show(created);
}
Inside the dialog, close it through the DialogInstance it cascades, not the service.
Close(data) ends OpenAsync with that data, and Cancel() ends it as
canceled. A Cancel button that reports something, such as how many people were added before one was refused,
calls Close with that instead.
DialogService Dialogs
<RadzenButton Text="Cancel" ...
Click="(() => Dialogs.Close(null))" />
<RadzenButton Text="Create key"
Click="CreateAsync" />
{
private async Task CreateAsync()
{
var created =
await Api.CreateKeyAsync(...);
Dialogs.Close(created.Value);
}
}<Button ... OnClick="() => Dialog.Cancel()">
Cancel
</Button>
<Button OnClick="CreateAsync">
Create key
</Button>
{
[CascadingParameter]
public DialogInstance Dialog { get; set; }
= null!;
private async Task CreateAsync()
{
var created =
await Api.CreateKeyAsync(...);
Dialog.Close(created.Value);
}
}DialogService.Close(data) is still there and closes the most recent dialog, as Radzen's does, but
DialogInstance closes only its own. Closing a dialog that has already ended does nothing, so a
request that comes back after its dialog was closed can't close the next one.
Confirm becomes ConfirmAsync, with the same message, title and options, and returns a
bool rather than a bool?. Give a removal Accent.Danger to make the confirm
button red:
var confirmed = await Dialogs.Confirm(
$"{member.Email} will no longer have access.",
"Remove from tenant?",
new ConfirmOptions
{
OkButtonText = "Remove",
CancelButtonText = "Cancel",
});
if (confirmed != true)
{
return;
}var confirmed = await Dialogs.ConfirmAsync(
$"{member.Email} will no longer have access.",
"Remove from tenant?",
new ConfirmOptions
{
ConfirmText = "Remove",
Accent = Accent.Danger,
});
if (!confirmed)
{
return;
}| Radzen | IzzyBlazor |
|---|---|
OpenAsync<T>(…) returns dynamic | OpenAsync<T>(…) returns a DialogResult: Data and Canceled |
DialogOptions.Width = "560px" | Width = Length.Px(560), or Size = DialogSize.Md |
Confirm(message, title, options) | ConfirmAsync(message, title, options) |
Confirm returns bool? | Returns bool |
ConfirmOptions.OkButtonText | ConfirmText |
ConfirmOptions.CancelButtonText | CancelText |
Dialogs.Close(result) in the dialog | Dialog.Close(result), on the cascaded DialogInstance |
Dialogs.Close(null) in the dialog | Dialog.Cancel() |
What behaves differently:
- Focus moves in as the dialog opens. The dialog is the browser's own (
showModal), so focus goes to the field withautofocus, else the first thing that takes focus, with no timer. Radzen moves it half a second later, so a test helper that waits for that, or for a timer left by the last dialog, can go. - Every dismissal is a cancel. Escape, a click on the backdrop and the close button end
OpenAsyncwithCanceledtrue and no data. - A confirm starts on Cancel. Cancel has the focus and Escape cancels. A confirm has no close button and ignores clicks on the backdrop, so it's answered with one of its buttons or Escape.
See Dialog, which also covers dialogs written in the page.
Tests
In bUnit, register IzzyBlazor's services instead of Radzen's. DialogService is sealed, so instead of
a subclass that records what it was closed with, render a DialogHost, open the dialog through the
service and await what it returns. The host shows dialogs only when it renders interactively, so set the
renderer to interactive with SetRendererInfo. A TextField takes its value on change,
so the test calls Change where it called Input.
Services.AddRadzenComponents();
Services.AddScoped<DialogService,
RecordingDialogs>();
var dialog = Render<KeyDialog>(
p => p.Add(d => d.View, view));
dialog.Find("#key-name").Input("Primary");
await dialog.Find("#key-create")
.ClickAsync(new());
Assert.IsType<ApiKey>(
Assert.Single(Dialogs.Closed));
// Records what the dialog closed with.
class RecordingDialogs(
NavigationManager navigation, IJSRuntime js)
: DialogService(navigation, js)
{
public List<object?> Closed { get; } = [];
public override void Close(
dynamic? result = null) =>
Closed.Add(result);
}Services.AddIzzyBlazorCore();
SetRendererInfo(new RendererInfo("Server",
isInteractive: true));
var dialogs =
Services.GetRequiredService<DialogService>();
var host = Render<DialogHost>();
Task<DialogResult> closed = null!;
await host.InvokeAsync(() =>
{
closed = dialogs.OpenAsync<KeyDialog>(
"Create a key",
new() { ["View"] = view });
});
host.WaitForAssertion(() =>
host.Find("#key-name"));
host.Find("#key-name").Change("Primary");
await host.Find("#key-create")
.ClickAsync(new());
var result = await closed;
Assert.IsType<ApiKey>(result.Data);
Open the dialog in a block lambda, as here: an expression lambda returns the task, and bUnit's
InvokeAsync then waits for the dialog to close before going on. A test that presses a button
through the component finds Button and calls its OnClick, where it found
RadzenButton and its Click; the attributes are in AdditionalAttributes.
Only ToastService calls JavaScript, so other components need no JS interop set up.
Browser tests that find Radzen's classes change to these:
| Radzen | IzzyBlazor |
|---|---|
.rz-dialog-confirm | The dialog by role and name, or dialog:has(.ib-confirm-message) |
.rz-dialog-title | .ib-dialog-title (an h2) |
.rz-dialog-confirm-message | .ib-confirm-message |
.rz-dialog | dialog, or .ib-dialog |
A dialog is named by its title, so finding it by role (GetByRole(AriaRole.Dialog, new() { Name = "Remove from
tenant?" })) holds up better than a class. A confirm without a title is named by its message. A test that
checks the page's stylesheets looks for _content/IzzyBlazor.Core/izzyblazor.css, with
@Assets's fingerprint, and no script tag.
Fields
Hand-built fields (a label, an input or textarea with Radzen's classes, a
hint) become TextField, TextArea, Select or DateField, which
draw the label, hint and errors and link them for screen readers. An id goes on the input.
A DateField reads and shows the time in UTC unless you set TimeZone. Bind a
DateTimeOffset for a date and time, or a DateOnly for a date alone. A
DateTime works too: one that isn't Local is read as UTC, and on an interactive page it
comes back with DateTimeKind.Utc.
<div class="rb-field">
<label for="key-name">Name</label>
<input id="key-name" class="rz-textbox"
maxlength="200" autocomplete="off"
@bind="_name" />
<span class="rb-field-hint">
Say who uses it.
</span>
</div>
<div class="rb-field">
<label for="log-from">From (UTC)</label>
<input id="log-from" type="datetime-local"
class="rz-textbox" @bind="_from" />
</div>
{
private DateTime? _from;
// The input's value has no kind: it's UTC.
private DateTimeOffset From() =>
new(DateTime.SpecifyKind(
_from!.Value, DateTimeKind.Utc));
}<TextField id="key-name" Label="Name"
@bind-Value="_name"
MaxLength="200" AutoComplete="off"
Hint="Say who uses it." />
<DateField id="log-from" Label="From (UTC)"
@bind-Value="_from" />
{
// Read in UTC unless TimeZone says otherwise.
private DateTimeOffset? _from;
private DateTimeOffset From() => _from!.Value;
}- The value changes when the field does, on leaving it or pressing Enter, as with Blazor's
InputText. A field bound with@bind:event="oninput", so that a Save button turns on as you type, needs an@oninputhandler on the field as well, which goes to the input. - Errors come from the form. A field shows its
EditContext's messages for its property. In aForm, add an API's refusal to aValidationMessageStoreand the field shows it; outside one, keep showing the message yourself.
See TextField, DateField and Forms and validation.
A diff
A hand-drawn line diff (old and new line numbers, added and removed marks, folded runs of unchanged lines) is a
Diff. Give it the two texts and it compares them, colors them as Code does and marks the
changed words. The notes for identical texts and for texts too far apart to compare are built in, with
SameText and TooManyText to change them; MaxChanges is 1,000 lines unless
set.
if (LineDiff.Compare(older, newer)
is not { } lines)
{
<p>The texts differ by too many lines.</p>
}
else if (lines.All(l => l.Kind == DiffKind.Same))
{
<p>The two texts are the same.</p>
}
else
{
<div class="rb-diff">
foreach (var hunk in LineDiff.Hunks(lines))
{
<div class="rb-diff-gap">⋯</div>
foreach (var line in hunk)
{
<div class="LineClass(line.Kind)">
<span>line.Old</span>
<span>line.New</span>
<span>Mark(line.Kind)</span>
<code>line.Text</code>
</div>
}
}
</div>
}<Diff OldText="older" NewText="newer"
Language="xml"
Title="($"Version {from} to {to}")" />
For a diff computed elsewhere, pass Lines instead; TextDiff.Compare is the comparison
on its own. Layout="DiffLayout.Split" puts the old text beside the new. See
Diff.
A side panel
For a list with a panel beside it about the chosen row, put an AppAside in the page. It takes the
shell's aside column for that page alone, scrolls on its own, and below its breakpoint goes under the main area
with SmallScreen="AsideSmallScreen.Below". The rows link to the chosen one with
RowHref, and RowCurrent marks it. The panel's tabs are a Tabs of
TabLinks (more on those below).
<div class="rb-columns">
<main>
<table>
foreach (var c in candidates)
{
<tr class="(c.Id == Shown
? "rb-row-selected" : null)">
<td>
<a href="Address(c.Id)">c.Id</a>
</td>
</tr>
}
</table>
</main>
<aside class="rb-panel" id="panel"
aria-label="Candidate">
<nav class="rb-side-tabs"
aria-label="Panel">
<a id="tab-candidate"
href="Address(Shown, null)"
aria-current="(PanelTab == null
? "page" : null)">Candidate</a>
<a id="tab-record"
href="Address(Shown, "record")"
aria-current="(PanelTab == "record"
? "page" : null)">Record</a>
</nav>
...
</aside>
</div>@* In the page; the layout's AppMain holds it. *@
<Table Items="candidates"
Caption="Candidates"
RowHref="c => Address(c.Id)"
RowLabel="c => c.Id"
RowCurrent="c => c.Id == Shown">
<PropertyColumn Property="c => c.Id" />
</Table>
<AppAside Width="Length.Px(420)"
SmallScreen="AsideSmallScreen.Below"
Label="Candidate" Id="panel">
<Tabs Label="Panel">
<TabLink id="tab-candidate"
Href="Address(Shown, null)"
Current="PanelTab == null"
Label="Candidate" />
<TabLink id="tab-record"
Href='Address(Shown, "record")'
Current='PanelTab == "record"'
Label="Record" />
</Tabs>
...
</AppAside>
This needs the layout to be an AppShell, with your bar as its
AppHeader and @Body in its AppMain. The page's aside moves into the shell
when the page and the layout render in the same mode, as they do when Routes is interactive. Use
AsideSmallScreen.Drawer instead for a panel people open from an AsideToggle on small
screens.
A tab link is current when the address matches it, query included. When the page shows a default the address
doesn't name, such as the first row before one is chosen, say which tab is current with Current, as
the panel above does.
Tabs and menus
A nav of links marked with aria-current is a Tabs of TabLinks.
The server marks the tab for the current address, and a page below a tab's address keeps that tab marked. When
the tabs are one page told apart by the query (?tab=record), only the tab whose query matches is
marked. Tabs whose panels are on the same page are Tabs with content.
<nav aria-label="Connection" class="rb-tabs">
foreach (var (segment, name) in tabs)
{
<a href="Tab(segment)"
aria-current="(segment == Current
? "page" : null)">name</a>
}
</nav><Tabs Label="Connection">
foreach (var (segment, name) in tabs)
{
<TabLink Href="Tab(segment)"
Label="name" />
}
</Tabs>
A menu component of your own, which closes on Escape, on a click outside and on navigation, and moves focus into
its panel, becomes IzzyBlazor's Menu. Opening, Escape and a click outside are the browser's (the
Popover API). Opening focuses the first item, arrow keys move between items, typing jumps to one, and Escape puts
focus back on the button. A MenuRadioGroup has a heading and marks the chosen item from its
Value; its items can still be links.
@* Components/Layout/Menu.razor, your own *@
<Menu Label="Switch tenant or environment"
ButtonClass="rb-switcher">
<Button>
tenant.Name · environmentName
</Button>
<ChildContent>
<span class="rb-group-heading">
tenant.Name
</span>
foreach (var env in environments)
{
<a role="menuitem" class="rb-menu-item"
href="Switch(tenant.Id, env)"
aria-current="(env == current
? "true" : null)">NameOf(env)</a>
}
<a role="menuitem" class="rb-menu-item"
href="tenants">All tenants</a>
</ChildContent>
</Menu><Menu Label=
"@($"{tenant.Name} · {environmentName}")">
<MenuRadioGroup Label="tenant.Name"
Value="current">
foreach (var env in environments)
{
<MenuItem Value="env"
Href="Switch(tenant.Id, env)">
NameOf(env)
</MenuItem>
}
</MenuRadioGroup>
<MenuDivider />
<MenuItem Href="tenants">
All tenants
</MenuItem>
</Menu>Label is the button's text, and its name for screen readers. A name that doesn't contain the visible
text, such as an aria-label of "Switch tenant or environment" on a button that reads "Alder ·
Production", fails WCAG's label-in-name rule, so the visible text names it. See
Tabs and Menu.
Drag and drop
A RadzenDropZoneContainer with RadzenDropZones becomes a DropZoneGroup
with a ListView per zone. As in Radzen, the group holds one list of items (Items,
Radzen's Data): ZoneOf takes the place of ItemSelector, each ListView's
Zone of the zone's Value, and Accepts of CanDrop. Save the move in OnMove,
which gets FromZone, ToZone and the item's place in each, in place of
Drop; the zone shows the item there before you've saved it. Each item's handle opens a menu with
the same moves (Move to and a zone, up and down), so moving works with the keyboard, on touch, and with
JavaScript off. Reordering a single list is a ListView with Reorderable. See
DropZoneGroup.
Next steps
- Components: Stepper, Switch, SegmentedControl and the rest of what you may have built by hand.
- Render modes: what changes if some pages become static.
- Tokens and customizing: CSS variables for your own styles beside the components.