What's New
Frappe feat ui-ux Editor's pick

Standard helper to render buttons

NI @nikkothari22 · #40576 · merged 5 Jul 2026

Until now, every Desk feature that wanted an Espresso-style button hand-wrote the markup: a template literal with class="es-button", the right data-variant and data-size attributes, a manually bound click handler, and — if the author remembered — an escaped label, an aria-label for icon-only buttons, and some ad-hoc loading flag. Grep the codebase and each call site did a slightly different subset of that. Drift was the default.

What changed

PR #40576 by Nikhil Kothari adds frappe/public/js/frappe/ui/components/button.js: one helper, two forms, with a full JSDoc typedef so your editor autocompletes every option.

  • frappe.ui.button(opts) returns a wired jQuery element. If onclick returns a promise, the button sets aria-busy, shows a spinner, and blocks clicks (including keyboard re-entry) until it settles.
  • frappe.ui.button.html(opts) returns a markup string for template literals.
  • Labels, titles, classes, and attribute values are HTML-escaped; on* attributes are refused outright, since entity decoding makes escaping useless there.
  • Icon without label renders a square icon-button and promotes title to aria-label; a missing title logs a console warning.
  • Loading state moved from data-loading to aria-busy="true" — visual and accessibility state can no longer drift — with automatic loading labels for common verbs (Save becomes "Saving...") and a new pure-CSS .es-spinner component.
frappe.ui.button({
    label: __("Save"),
    variant: "solid",
    onclick: () => frm.save(), // promise → busy state handled for you
});

// string form, for template literals
`<div>${frappe.ui.button.html({ label: __("Load More") })}</div>`;

The PR also converts every existing es-button call site; a follow-up will migrate legacy btn markup.

> Return a promise from onclick and the loading state — spinner, aria-busy, click blocking — is handled for you. Stop hand-rolling busy flags.