Skip to content
IzzyBlazor 0.1.0-alpha.20

Enter all results ↑ ↓ move Esc close

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 and IzzyBlazor
Radzen, or built by handIzzyBlazor
RadzenButtonButton, IconButton
RadzenDataGridTable, with PropertyColumn and TemplateColumn
DialogServiceDialogService, 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 cursorTable with Paging
A line diffDiff
<main> and <aside>AppShell, with an AppAside in the page
A nav of links as tabsTabs with TabLink
A dropdown menuMenu
RadzenDropZoneContainer, RadzenDropZoneDropZoneGroup, 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" />.

Radzen
// 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 />
IzzyBlazor
// 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.

Radzen
<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>
IzzyBlazor
<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 members
RadzenButtonButton
Text="Save"Content: <Button>Save</Button>
ClickOnClick
ButtonStyle.PrimaryAccent.Primary (the default)
ButtonStyle.BaseAccent.Neutral
ButtonStyle.DangerAccent.Danger
Variant.FilledButtonVariant.Solid (the default)
Variant.OutlinedButtonVariant.Outline
Variant.TextButtonVariant.Ghost
ButtonSize.SmallControlSize.Sm
Icon="close" with no textIconButton with Icon="Lucide.X" and a Label
IsBusyLoading
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.

Radzen
<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>
IzzyBlazor
<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 members
RadzenDataGridTable
DataItems
TItemInferred 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 gridSortable on each column
AllowFiltering, FilterMode.SimpleFilterable on each column
FilterCaseSensitivity.CaseInsensitiveNone: 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, RowDeselectSelectionChanged

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 a QueryPrefix. 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. FilterPanel adds 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, and SelectionActions go in a bar above the rows. SelectAllMode says 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 Table column sorts or filters only with Sortable or Filterable.
  • A template column sorts by what you say. Radzen sorts a template column by its Property; a TemplateColumn needs SortBy and a SortKey for the address, and FilterBy to filter.
  • Caption names the table for screen readers; it isn't shown. An id goes on the table's outer element, so #members tr still 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.

Built by hand
<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>
}

@code {
  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;
  }
}
IzzyBlazor
<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>

@code {
  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.

Radzen
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);
}
IzzyBlazor
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.

Radzen
@inject DialogService Dialogs

<RadzenButton Text="Cancel" ...
  Click="@(() => Dialogs.Close(null))" />
<RadzenButton Text="Create key"
  Click="CreateAsync" />

@code {
  private async Task CreateAsync()
  {
    var created =
      await Api.CreateKeyAsync(...);
    Dialogs.Close(created.Value);
  }
}
IzzyBlazor
<Button ... OnClick="() => Dialog.Cancel()">
  Cancel
</Button>
<Button OnClick="CreateAsync">
  Create key
</Button>

@code {
  [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:

Radzen
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;
}
IzzyBlazor
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;
}
DialogService members
RadzenIzzyBlazor
OpenAsync<T>(…) returns dynamicOpenAsync<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.OkButtonTextConfirmText
ConfirmOptions.CancelButtonTextCancelText
Dialogs.Close(result) in the dialogDialog.Close(result), on the cascaded DialogInstance
Dialogs.Close(null) in the dialogDialog.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 with autofocus, 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 OpenAsync with Canceled true 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.

Radzen
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);
}
IzzyBlazor
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:

Test selectors
RadzenIzzyBlazor
.rz-dialog-confirmThe 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-dialogdialog, 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.

Built by hand
<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>

@code {
  private DateTime? _from;

  // The input's value has no kind: it's UTC.
  private DateTimeOffset From() =>
    new(DateTime.SpecifyKind(
      _from!.Value, DateTimeKind.Utc));
}
IzzyBlazor
<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" />

@code {
  // 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 @oninput handler 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 a Form, add an API's refusal to a ValidationMessageStore and 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.

Built by hand
@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>
}
IzzyBlazor
<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).

Built by hand
<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>
IzzyBlazor
@* 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.

Built by hand
<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>
IzzyBlazor
<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.

Your own component
@* 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>
IzzyBlazor
<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.

Connection lost

Rejoining the server…