Exclusifbêta

Busy Cursor <mv-busy-cursor>

« Veuillez patienter » local pour une seule région (un tableau qui se recharge, un graphique qui se recalcule, un panneau qui enregistre) : tant qu’elle est occupée, un petit anneau accompagne le curseur système avec une progression indéterminée ou réelle, la région reçoit cursor: progress et aria-busy, et les clics peuvent être retenus, puis rejoués une fois ou abandonnés avec une secousse. Ce que d’autres oublient : il ne s’affiche que pour les attentes de plus de 300 ms, ne remplace jamais le curseur, et annonce une seule fois, poliment, « Chargement… » / « Terminé » depuis l’extérieur du sous-arbre aria-busy, où les lecteurs d’écran retiendraient sinon l’annonce.

CatégorieCurseurs
TypeWeb Component (<mv-busy-cursor>)
Statutbêta
Keywordsexclusive, light, cursor, busy, loading, aria-busy, progress, wait, spinner, pointer

When to use

  • A table, list or chart reloads in place and users keep clicking it because nothing near the pointer says wait
  • One panel saves or recomputes while the rest of the page stays usable, so a page-wide spinner would be too blunt
  • Clicks made during a short reload should wait and run once the data is fresh instead of acting on stale rows
  • A background job reports real progress and the region should show it without covering the content

Avoid when

  • The content is not there yet and needs placeholders in its final shape → use Skeleton instead
  • The wait lasts minutes and needs an honest ETA, a queue position or a stuck warning → use Honest Wait instead
  • The design wants a decorative custom cursor with a follower ring and labels → use Cursor Follower instead

Installation

node scripts/add.mjs busy-cursor --out ./src/marvelous

Agent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["busy-cursor"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/busy-cursor/busy-cursor.js, components/busy-cursor/busy-cursor.css.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<mv-busy-cursor id="orders"> …your table… </mv-busy-cursor>
<!-- then: orders.track(fetch("/api/orders")) or orders.busy = true -->

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :

<div id="mv-bc-demo">
  <style>
    #mv-bc-demo { display: grid; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); gap: 1rem; width: min(100%, 56rem); margin-inline: auto; }
    #mv-bc-demo .panel { display: grid; grid-template-rows: auto 1fr auto; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); background: var(--mv-surface); overflow: hidden; }
    #mv-bc-demo .head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .6rem .6rem .6rem 1rem; border-bottom: 1px solid var(--mv-border); }
    #mv-bc-demo .head strong { font-size: .9rem; font-weight: var(--mv-weight-semibold); }
    #mv-bc-demo .head span { display: block; color: var(--mv-fg-subtle); font-size: .75rem; }
    #mv-bc-demo table { width: 100%; border-collapse: collapse; font-size: .82rem; }
    #mv-bc-demo td { padding: .5rem 1rem; border-bottom: 1px solid var(--mv-border); white-space: nowrap; }
    #mv-bc-demo tr:last-child td { border-bottom: 0; }
    #mv-bc-demo td:nth-child(2) { color: var(--mv-fg-muted); width: 100%; }
    #mv-bc-demo td.num { text-align: end; font-variant-numeric: tabular-nums; }
    #mv-bc-demo td:last-child { padding-inline-start: 0; text-align: end; }
    #mv-bc-demo .done { color: var(--mv-fg-subtle); font-size: .75rem; }
    #mv-bc-demo .foot { padding: .55rem 1rem; border-top: 1px solid var(--mv-border); color: var(--mv-fg-muted); font-size: .75rem; min-height: 1.1rem; }
    #mv-bc-demo .chart { display: flex; gap: .45rem; padding: 1rem 1rem .5rem; }
    #mv-bc-demo .bar { flex: 1; display: grid; grid-template-rows: 7.5rem auto; justify-items: center; gap: .35rem; color: var(--mv-fg-subtle); font-size: .7rem; }
    #mv-bc-demo .col { display: flex; align-items: flex-end; width: 100%; }
    #mv-bc-demo .bar i { display: block; width: 100%; border-radius: var(--mv-radius-sm) var(--mv-radius-sm) 2px 2px; background: color-mix(in oklch, var(--mv-accent) 70%, var(--mv-surface)); transition: height var(--mv-duration-slow) var(--mv-ease-out); }
    #mv-bc-demo .range { display: flex; gap: .25rem; padding: 0 1rem .75rem; }
  </style>

  <section class="panel" aria-labelledby="mv-bc-orders-title">
    <div class="head">
      <div><strong id="mv-bc-orders-title">Recent orders</strong><span id="mv-bc-orders-time">Updated 10:42 AM</span></div>
      <button class="mv-button" data-variant="outline" data-size="sm" id="mv-bc-refresh">Refresh</button>
    </div>
    <mv-busy-cursor id="mv-bc-orders" dim hold-clicks>
      <table>
        <tbody>
          <tr><td>#1042</td><td>Amara Okafor</td><td class="num">$184.00</td><td><button class="mv-button" data-variant="ghost" data-size="sm" data-refund>Refund</button></td></tr>
          <tr><td>#1041</td><td>Mateo Rossi</td><td class="num">$62.50</td><td><button class="mv-button" data-variant="ghost" data-size="sm" data-refund>Refund</button></td></tr>
          <tr><td>#1040</td><td>Yuki Tanaka</td><td class="num">$1,249.99</td><td><button class="mv-button" data-variant="ghost" data-size="sm" data-refund>Refund</button></td></tr>
          <tr><td>#1039</td><td>Sofia Lindqvist</td><td class="num">$38.00</td><td><button class="mv-button" data-variant="ghost" data-size="sm" data-refund>Refund</button></td></tr>
        </tbody>
      </table>
    </mv-busy-cursor>
    <div class="foot" id="mv-bc-orders-log">Refresh, then click Refund while it loads: the click waits and runs once.</div>
  </section>

  <section class="panel" aria-labelledby="mv-bc-chart-title">
    <div class="head">
      <div><strong id="mv-bc-chart-title">Revenue by week</strong><span id="mv-bc-chart-range">Last 6 weeks · USD</span></div>
      <button class="mv-button" data-variant="outline" data-size="sm" id="mv-bc-recompute">Recompute</button>
    </div>
    <mv-busy-cursor id="mv-bc-chart" hold-clicks="drop">
      <div class="chart" id="mv-bc-bars"></div>
      <div class="range" role="group" aria-label="Range">
        <button class="mv-button" data-variant="secondary" data-size="sm" aria-pressed="true" data-weeks="6">6 weeks</button>
        <button class="mv-button" data-variant="ghost" data-size="sm" aria-pressed="false" data-weeks="12">12 weeks</button>
      </div>
    </mv-busy-cursor>
    <div class="foot">Real progress in the ring; clicks during the recompute are dropped with a shake.</div>
  </section>

  <script type="module">
    const $ = (id) => document.getElementById(id);
    const wait = (ms) => new Promise((r) => setTimeout(r, ms));
    const time = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" });
    const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

    const orders = $("mv-bc-orders");
    const log = $("mv-bc-orders-log");
    $("mv-bc-refresh").addEventListener("click", () => {
      orders.track(wait(2200)).then(() => { $("mv-bc-orders-time").textContent = `Updated ${time.format(new Date())}`; });
    });
    orders.addEventListener("click", (e) => {
      const button = e.target.closest("[data-refund]");
      if (!button) return;
      const row = button.closest("tr");
      button.replaceWith(Object.assign(document.createElement("span"), { className: "done", textContent: "Refunded" }));
      log.textContent = `Refund issued for ${row.cells[0].textContent} (${row.cells[1].textContent}).`;
    });
    orders.addEventListener("mv-click-held", () => { log.textContent = "Click held: it will run once the list is up to date."; });

    const chart = $("mv-bc-chart");
    const bars = $("mv-bc-bars");
    const draw = (weeks) => {
      const values = Array.from({ length: 6 }, (_, i) => 18000 + Math.round(Math.sin(i * 1.3 + weeks) * 6000 + i * 1500 * (weeks / 6)));
      const max = Math.max(...values);
      bars.replaceChildren(...values.map((v, i) => {
        const bar = document.createElement("div");
        bar.className = "bar";
        bar.title = money.format(v);
        const fill = document.createElement("i");
        fill.style.height = `${Math.round((v / max) * 100)}%`;
        const col = document.createElement("div");
        col.className = "col";
        col.append(fill);
        bar.append(col, `W${i + 1}`);
        return bar;
      }));
    };
    const recompute = async (weeks) => {
      if (chart.busy) return;
      chart.progress = 0;
      await chart.track(async () => {
        for (let i = 1; i <= 10; i++) { await wait(260); chart.progress = i / 10; }
      });
      chart.progress = null;
      draw(weeks);
    };
    let weeks = 6;
    draw(weeks);
    $("mv-bc-recompute").addEventListener("click", () => recompute(weeks));
    chart.querySelectorAll("[data-weeks]").forEach((b) => b.addEventListener("click", () => {
      weeks = Number(b.dataset.weeks);
      chart.querySelectorAll("[data-weeks]").forEach((o) => {
        o.setAttribute("aria-pressed", String(o === b));
        o.dataset.variant = o === b ? "secondary" : "ghost";
      });
      $("mv-bc-chart-range").textContent = `Last ${weeks} weeks · USD`;
      recompute(weeks);
    }));
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
busybooleanThe region is working. Also settable as el.busy = true, or managed by track().
progressnumber (0-1)Real progress drawn as an arc in the ring. Absent or empty: indeterminate spin.
dimbooleanSoftly dims the region’s links, buttons and form controls while the ring is shown (they stay clickable).
hold-clicks(empty) | replay | dropHolds clicks on interactive elements while busy. Empty or replay: the last one runs once when ready (if its element still exists). drop: it is discarded and the ring shakes. Absent: clicks go through.
delaynumber (ms)300How long the region must stay busy before the ring, cursor and announcement appear (no flicker on fast loads). A held click shows them at once.
data-statebusySet by the component on itself while the busy state is shown (drives cursor: progress and dim).

Properties

NameTypeDefaultDescription
busybooleanMirror of the attribute.
progressnumber | nullMirror of the attribute; null returns to indeterminate.
strings{ loading, done, wait }{ loading: "Loading…", done: "Done", wait: "Still loading. Try again in a moment." }Announced texts, for translation or context (e.g. { loading: "Refreshing orders…", done: "Orders updated" }). wait is announced when a keyboard or assistive-tech activation is dropped.

Methods

NameDescription
track(work)Busy until work (a promise, or a function returning one) settles; overlapping calls stay busy until the last one. Returns a promise with the same outcome.

Events

NameDescription
mv-busy-changeThe busy state changed (immediately, not delayed); detail = { busy }.
mv-click-heldA click is about to be held; detail = { target, mode }. Cancelable: preventDefault() lets this click through.
mv-replayA held click is about to run; detail = { target }. Cancelable: preventDefault() skips it.

Content structure

NameDescription
(content)The region: any content. The ring is added only while shown, and re-added if you replace the children.

CSS classes

NameDescription
mv-busy-cursor-ringThe ring beside the pointer (aria-hidden, pointer-events: none). data-on while visible, data-determinate with progress, data-state="done", data-shake.
mv-busy-cursor-svg / -track / -arcIts SVG parts.

CSS variables

NameDefaultDescription
--mv-busy-cursor-colorvar(--mv-accent)Arc color.
--mv-busy-cursor-trackarc color at 20%Track under the arc.
--mv-busy-cursor-donevar(--mv-success)Arc color of the short “done” finish.
--mv-busy-cursor-heldvar(--mv-warning)Arc color while a dropped click shakes the ring.
--mv-busy-cursor-size1.375remRing diameter.
--mv-busy-cursor-dim0.5Opacity of interactive parts with dim.

Accessibility

The system cursor is never hidden or replaced: the ring sits beside it (offset, flipped near the region’s edges) and the region uses the standard cursor: progress, so OS cursor size and contrast settings keep working. The ring is decorative (aria-hidden, pointer-events: none) and drawn only under (hover: hover) and (pointer: fine); touch input is ignored. The region gets aria-busy="true" as soon as it is busy. If the wait lasts longer than the delay, one polite role=status message says “Loading…”, then “Done” when it ends; it is never repeated, progress changes are not announced, and the status region lives outside the busy subtree because screen readers may hold back live changes inside aria-busy. Keyboard and assistive-tech activations follow the same hold rules as pointer clicks; a dropped one announces “Still loading. Try again in a moment.” since the ring is not visible to them. All texts come from strings (English defaults). Held clicks are replayed with the element’s own click(), only if it is still in the page and not disabled. dim lowers opacity only, never removes interactivity or focus. Reduced motion (OS or data-motion="reduce"): no spin or shake, a static arc, and a held click only tints the ring. Forced colors: Canvas disc, Highlight arc, CanvasText outline. Removing the element clears its timers, listeners, ring and status region.

Cette page a été traduite par IA. Signaler un problème de traduction