github tokio-rs/topcoat v0.11.0

3 hours ago
  • Localization
    • Fluent translations
    • Number and date formatting
    • Translation checks
  • Creating apps
  • Element IDs
  • Popovers
  • Navigation menu
  • Edited form controls
  • Errors from memoized functions
  • New demo and examples
  • Documentation and fixes
  • Upgrade guide
    • Rust version
    • Dropdown menus
    • Rerendered inputs
    • Formatting ignored files

Localization

You can now render your app in different languages. The new l10n module lets you choose a locale for each request, translate text with Fluent files, and format numbers and dates for the locale. Translations work in pages, but also in emails, push notifications, and any other text that has access to a context.

use topcoat::{
    Result,
    context::Cx,
    l10n::{accept_language, fluent::messages, locale, negotiate},
    router::{Body, Next, layer, page, response::Response},
    view::{View, view},
};

// Loads `src/l10n/en.ftl`, `src/l10n/de.ftl` and so on, and creates `t!`.
messages!("./l10n/{locale}.ftl");

#[layer]
async fn layer(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
    let locale = negotiate(accept_language(cx), t::locales())
        .cloned()
        .unwrap_or(locale!("en"));
    next.run(&cx.with(locale), body).await
}

#[page]
async fn home(cx: &Cx) -> Result<impl View> {
    Ok(view! {
        <!DOCTYPE html>
        <html lang=(locale(cx).id()) dir=(locale(cx).id().direction())>
            <body>
                <h1>(t!(title))</h1>
                <p>(t!(greeting, name = "Ada"))</p>
            </body>
        </html>
    })
}

To get started, enable the l10n-fluent feature of topcoat. A Locale such as en, zh-Hant, or de-CH is stored in the context with cx.with(locale), and everything rendered with that context reads it through locale(cx). Set the locale in a #[layer] so it reaches the page. You can pick it from the browser's languages with accept_language and negotiate, as above, or from the URL with a module_param! of type Locale, as in /en/about and /de/about. See localization and the complete app in examples/l10n.

Fluent translations

Fluent messages can use variables and pick their words by number, so translators can write natural sentences without changing your code. Put each locale's messages in a file named after the locale, such as src/l10n/en.ftl:

title = Welcome
greeting = Hello, { $name }!
items =
    { $count ->
        [one] One item
       *[other] { $count } items
    }
search = Search
    .placeholder = Type to search

messages! loads the files at compile time and creates a t! macro and a t::locales() function in its module. Pass a message ID to t!, followed by its variables. Add .name to select an attribute:

Ok(view! {
    <h1>(t!(title))</h1>
    <p>(t!(items, count))</p>
    <input aria-label=(t!(search)) placeholder=(t!(search.placeholder))>
})

In a view, a translation uses the locale in the current context. Call .resolve(cx) or .resolve_in(&locale) when you need the text itself, for example for an email subject.

You can call messages! in several modules to give each page or feature its own messages. Each call creates its own t!, so modules can reuse message IDs. With "{mod}/l10n/{locale}.ftl", src/admin.rs loads src/admin/l10n/en.ftl, which keeps translations next to the code that uses them. Wildcards such as "./l10n/{locale}/**/*.ftl" split a locale across several files.

Regional files only need the messages that differ. de-CH.ftl falls back to de.ftl for anything it does not define. When no message is available, Topcoat shows its key and arguments, such as greeting(name = "Ada"), instead of switching to another language. See Fluent.

Number and date formatting

Numbers and dates now display in the format of the locale. Enable l10n-number for Number, whose options follow JavaScript's Intl.NumberFormat. Enable l10n-datetime for DateTime, with date and time styles that follow Intl.DateTimeFormat:

use topcoat::l10n::{DateTime, DateTimeStyle, Number, locale};

let price = Number::new(1234.5).min_fraction_digits(2);
assert_eq!(price.resolve_in(&locale!("de")), "1.234,50");

let placed = DateTime::date(2026, 10, 12).unwrap().at(14, 5, 0).unwrap();
assert_eq!(placed.resolve_in(&locale!("de")), "12.10.2026, 14:05");
assert_eq!(
    placed.date_style(DateTimeStyle::Long).resolve_in(&locale!("de")),
    "12. Oktober 2026",
);

Both types render directly in views, as in <td>(price)</td>. With the jiff feature, Zoned and Timestamp values convert into DateTime. Fluent messages format number and date arguments too, with options set through NUMBER($amount, minimumFractionDigits: 2) and DATETIME($date, dateStyle: "long"). See formatting.

Translation checks

The new topcoat l10n check command checks your translations against the t! calls in your app:

topcoat l10n check

It reports missing messages and attributes, variables that a t! call does not supply, and references to missing messages or terms. It also warns about unused messages and arguments. Errors produce a nonzero exit status, so you can use the command in CI. topcoat dev runs the same check after each build and warns about problems. It also rebuilds when you add a new .ftl file, which Cargo does not notice on its own.

Creating apps

The new topcoat new command creates a ready-to-run Topcoat app. Run it without options to choose features in a wizard, or use a preset:

topcoat new my-app --recommended
topcoat new my-app --minimal

--recommended includes module routing, Toasty with SQLite, the browser runtime, Tailwind CSS, Topcoat UI, Iconify icons, a Fontsource font, and a todo example. --minimal includes module routing and a plain stylesheet. Individual flags such as --database none, --interaction htmx, or --no-ui change a preset's choices. Pass --no-interactive in scripts to make sure the command never asks questions.

The getting started guide now uses topcoat new. The previous steps moved to manual setup. See topcoat new for all options.

Element IDs

Labels, inputs, popovers, and ARIA attributes often need to refer to each other by ID. The new element_id(cx) function creates an HTML ID for this. Each component instance gets its own IDs, and they stay the same when the component renders again:

use topcoat::{
    Result,
    context::Cx,
    view::{View, component, element_id, view},
};

#[component]
async fn email_field(cx: &Cx) -> Result<impl View> {
    Ok(view! {
        let id = element_id(cx);
        <label for=(&id)>"Email"</label>
        <input id=(&id) type="email" name="email">
    })
}

The ID depends on the component's identity and where you call element_id. When a view! loop renders such a component, add #[key(...)] to the loop. See element IDs.

Popovers

Topcoat UI now has a popover component: a panel that opens beside a button, without JavaScript. Clicking outside or pressing Escape closes it, and parents with hidden overflow do not cut it off.

Ok(view! {
    let share = element_id(cx);
    popover(
        popover_trigger(target: &share, "Share")
        popover_content(id: &share, align: FloatingAlign::End, share_form())
    )
})

Dropdown menus now work the same way, and their menus close on an outside click. Popovers, dropdown menus, tooltips, and hover cards take side and align props to choose where they open. The browser moves them to another side when there is not enough room in the window. These components share the new floating registry component, which topcoat ui add installs with them. See UI components and the upgrade guide below.

Navigation menu

The new navigation_menu component shows a row of site links. Some of them can open a panel with further links on hover, focus, or tap, without JavaScript.

Ok(view! {
    navigation_menu(
        navigation_menu_list(
            navigation_menu_item(
                navigation_menu_trigger("Guides")
                navigation_menu_content(
                    navigation_menu_link(attrs: attributes! { href="/guides/start" }, "Getting started")
                )
            )
            navigation_menu_item(
                navigation_menu_link(
                    variant: NavigationMenuLinkVariant::Trigger,
                    attrs: attributes! { href="/blog" },
                    "Blog"
                )
            )
        )
    )
})

Install it with topcoat ui add navigation_menu.

Edited form controls

When a page or shard renders again in the browser, inputs the user has edited now keep their text. Inputs the user has not touched take the newly rendered value. This also applies to :value bindings that read no signals. To set an input's value on every render anyway, use the new Expr::dynamic:

use topcoat::runtime::Expr;

// Every render clears what the user typed.
let draft = Expr::from(String::new()).dynamic();

Ok(view! { <input :value=(draft)> })

See runtime.

Errors from memoized functions

? now converts a borrowed &topcoat::Error into topcoat::Error. Memoized functions with #[memoize(as_ref)] return Result<&T, &Error>, so you can now propagate their errors directly:

#[memoize(as_ref)]
async fn current_user(cx: &Cx) -> Result<User> {
    load_user(cx).await
}

let user = current_user(cx).await?;

See memoize.

New demo and examples

  • The new admin panel demo is an online store back office with sign-in, orders, products, and customers. It uses Toasty with SQLite, session cookies, Topcoat UI, and Fluent translations in English and German. It replaces the coffee shop demo. Run it with cargo topcoat dev -p admin from the repository root.
  • The new Cloudflare Workers example passes each request to Router::handle from a Worker's fetch handler, without starting a server.

Documentation and fixes

  • The link guide explains how to store links to different handlers in one list with &(dyn HrefTarget + Sync).
  • Example READMEs explain that you need to clone the whole repository to run them.
  • topcoat dev restarts the app when only bundled assets change, such as a stylesheet.
  • Hot reload keeps working after client side navigation within the same document.
  • Fast back and forward navigation no longer shows an older page after a newer one.
  • Runtime text expressions update correctly after content around them is replaced.
  • Signal values and expressions that contain < no longer break hydration.
  • Very large views with hundreds of dynamic parts no longer hit the compiler's recursion limit.
  • Go-to-definition on a component name in view! now goes to the component only.
  • Module discovery only registers handlers below the router's root module, so several module routers can live in one binary.
  • Cookie prefix adapters such as prefix_host accept names that already carry the prefix, like __Host-session.
  • Bundled asset URLs percent-encode filenames with spaces or other special characters.
  • Last-Event-ID headers with non-ASCII characters are decoded as UTF-8.
  • The built-in server now applies Hyper's timeouts. For example, it closes connections that do not send their request headers within 30 seconds.
  • The demo's SQL migrations are checked out with LF line endings, so they also run on Windows.

Upgrade guide

Rust version

Topcoat now requires Rust 1.99 or newer. Update with rustup update.

Dropdown menus

The dropdown_menu component now uses the browser's popover feature instead of a <details> element. Your existing copy keeps working until you replace it with topcoat ui add dropdown_menu --overwrite. After that, create an ID with element_id(cx) and pass it to the trigger's target and the content's id. Pass it to close on items that should close the menu:

+let menu = element_id(cx);
 dropdown_menu(
-    dropdown_menu_trigger("Options")
+    dropdown_menu_trigger(target: &menu, "Options")
     dropdown_menu_content(
-        dropdown_menu_item("Rename")
+        id: &menu,
+        dropdown_menu_item(close: Some(&menu), "Rename")
     )
 )

Submenus follow the same pattern with dropdown_menu_sub_trigger(target: ...) and dropdown_menu_sub_content(id: ...). Replace group-open: classes with group-has-[:popover-open]/dropdown-menu:. The menu now opens centered below the button by default. Pass align: FloatingAlign::Start to keep it aligned to the left edge.

Rerendered inputs

Rerendering no longer replaces text the user has typed into an input. If your app relied on a rerender to reset an input, bind its value with an expression that reads a signal, or call Expr::dynamic on it, as shown above.

Formatting ignored files

Without file arguments, topcoat fmt now skips files ignored by Git and Cargo's target directory. Pass paths explicitly to format ignored files. .gitignore rules only apply inside a Git repository, as in Git.

Don't miss a new topcoat release

NewReleases is sending notifications on new releases.