Exclusivobeta

Hatch <mv-hatch>

“Interruptor do homem morto” para a expiração de sessão: um chip discreto com contagem regressiva (algarismos tabulares que rolam como um contador mecânico) que alguém precisa zerar deliberadamente antes que ele chegue a zero. Abaixo do limite warn, ele vira um painel de alarme visível, mas não modal (ou um <dialog> com modal), com uma contagem regressiva grande, um medidor que se esvazia e “Stay signed in” / “Sign out”; pode-se exigir a digitação de um código de confirmação para zerar (quiosques, alta segurança) e ativar um bipe suave com WebAudio. As reinicializações passam por um evento cancelável mv-reset cujo waitUntil(promise) mostra um estado pendente enquanto o seu endpoint de keepalive responde e trata as falhas. O tempo é derivado de um prazo baseado em Date.now() (resistente a abas em segundo plano desaceleradas), as reinicializações e expirações são sincronizadas entre abas com um BroadcastChannel, e a atividade do usuário pode estender a sessão em silêncio apenas durante a fase tranquila, nunca depois que o alarme dispara.

CategoriaFeedback
TipoWeb Component (<mv-hatch>)
Statusbeta
KitEstados do sistema honestos
Também instalabutton, input
Keywordsexclusive, culture, session, timeout, session-timeout, idle, keepalive, countdown, timer, expiry, logout, dead-mans-switch, security, kiosk, broadcast-channel, multi-tab, alarm, flip-digits

When to use

  • An authenticated app signs users out after a fixed or idle timeout and must warn them before unsaved work is lost
  • A shared kiosk or back-office terminal must prove someone is still present before extending a sensitive session
  • Several tabs share one server session and a keepalive in one tab must extend or end the session in all of them
  • Compliance requires a visible countdown and a deliberate, logged action to extend a session

Avoid when

  • The countdown is a product feature (sale ends, auction, quiz) rather than a session that must be kept alive
  • The user just needs to be told what changed while they were away, not to confirm presence → use While Away instead
  • The session never expires client-side or the server silently refreshes tokens; a countdown would only add anxiety

Instalação

node scripts/add.mjs hatch --out ./src/marvelous

Agente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["hatch"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/hatch/hatch.js, components/hatch/hatch.css, components/button/button.css, components/input/input.css.

Uso

Início rápido, a menor marcação que funciona:

<mv-hatch duration="15m" warn="2m" channel="app-session">
  <a slot="expired" href="/login">Sign in again</a>
</mv-hatch>

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:

<div id="ht-demo" style="width:min(100%,52rem);margin-inline:auto">
  <style>
    #ht-demo { display:grid; gap:1rem; align-content:start }
    #ht-demo .ht-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #ht-demo .ht-bar { display:flex; align-items:center; gap:1rem; height:3.5rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
    #ht-demo .ht-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
    #ht-demo .ht-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #ht-demo .ht-logo svg { width:1rem; height:1rem }
    #ht-demo .ht-nav { display:flex; gap:.25rem; margin:0; padding:0; list-style:none }
    #ht-demo .ht-nav a { display:block; padding:.375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
    #ht-demo .ht-nav a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
    #ht-demo .ht-end { display:flex; align-items:center; gap:.75rem; margin-inline-start:auto }
    #ht-demo .ht-me { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.75rem; font-weight:600 }
    #ht-demo .ht-body { display:grid; grid-template-columns:minmax(0,1fr) 15rem; gap:1rem; padding:1.125rem }
    #ht-demo .ht-card { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
    #ht-demo .ht-card h4 { margin:0 0 .25rem; font-size:.875rem }
    #ht-demo .ht-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #ht-demo .ht-rows { display:grid; gap:.5rem; margin-top:.875rem }
    #ht-demo .ht-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); font-size:.8125rem }
    #ht-demo .ht-row span:last-child { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
    #ht-demo .ht-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #ht-demo .ht-controls p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; flex:1 1 16rem }
    #ht-demo .ht-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
    #ht-demo .ht-log { min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
    #ht-demo .mv-choice { font-size:.8125rem }
    #ht-demo .ht-kiosk { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #ht-demo .ht-kiosk-title { display:grid; gap:.125rem; flex:1 1 14rem; min-width:0 }
    #ht-demo .ht-kiosk-title strong { font-size:.9375rem; letter-spacing:-.01em }
    #ht-demo .ht-kiosk-title span { color:var(--mv-fg-muted); font-size:.8125rem }
    @media (max-width:40rem) {
      #ht-demo .ht-nav { display:none }
      #ht-demo .ht-body { grid-template-columns:minmax(0,1fr) }
    }
  </style>

  <!-- Main: a billing console with a 45-second session (accelerated), alarm in the last 20 seconds -->
  <section class="ht-app" aria-label="Billing console">
    <header class="ht-bar">
      <span class="ht-brand">
        <span class="ht-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16v10H4z"/><path d="M4 11h16"/></svg></span>
        Ledgerly
      </span>
      <ul class="ht-nav">
        <li><a href="#" aria-current="page">Invoices</a></li>
        <li><a href="#">Customers</a></li>
        <li><a href="#">Payouts</a></li>
      </ul>
      <span class="ht-end">
        <mv-hatch id="ht-main" duration="45s" warn="20s" channel="ht-demo-session">
          <button slot="expired" type="button" class="mv-button" id="ht-signin">Sign in again</button>
        </mv-hatch>
        <span class="ht-me" aria-hidden="true">MR</span>
      </span>
    </header>
    <div class="ht-body">
      <div class="ht-card">
        <h4>Invoice INV-2048 · Draft</h4>
        <p>Northwind Traders · due Oct 15, 2026. Unsaved edits are at risk if the session ends.</p>
        <div class="ht-rows">
          <div class="ht-row"><span>Annual platform license</span><span>$12,400.00</span></div>
          <div class="ht-row"><span>Onboarding workshop (2 days)</span><span>$3,200.00</span></div>
          <div class="ht-row"><span>Priority support</span><span>$1,800.00</span></div>
        </div>
      </div>
      <div class="ht-card">
        <h4>Keepalive</h4>
        <p>Each reset pings the server before the timer restarts.</p>
        <p class="ht-log" id="ht-log" aria-live="polite" style="margin-top:.75rem">Session started.</p>
      </div>
    </div>
    <div class="ht-controls">
      <p>The session lasts 45 seconds here. Open this page in two tabs: extending in one extends both.</p>
      <span class="ht-actions">
        <label class="mv-choice"><input type="checkbox" class="mv-checkbox" id="ht-fail"> Make the keepalive fail</label>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ht-ff">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 6 7 6-7 6zM13 6l7 6-7 6z"/></svg>
          Fast-forward to alarm
        </button>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="ht-restart">Restart</button>
      </span>
    </div>
  </section>

  <!-- Kiosk: resetting requires typing a short code -->
  <div class="ht-kiosk">
    <span class="ht-kiosk-title">
      <strong>Front desk kiosk · Terminal 3</strong>
      <span>Shared device: staying signed in requires typing a confirmation code.</span>
    </span>
    <mv-hatch id="ht-kiosk" duration="2m" warn="30s" code="K7M2" label="Kiosk"></mv-hatch>
    <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ht-kiosk-ff">Fast-forward to alarm</button>
  </div>

  <script type="module">
    const main = document.getElementById("ht-main");
    const kiosk = document.getElementById("ht-kiosk");
    const log = document.getElementById("ht-log");
    const fail = document.getElementById("ht-fail");
    const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    const say = (text) => { log.textContent = `${time()} · ${text}`; };

    // A fake keepalive endpoint: 600 ms round trip, optionally failing.
    const keepalive = () => new Promise((resolve, reject) => {
      setTimeout(() => (fail.checked ? reject(new Error("503")) : resolve()), 600);
    });

    main.addEventListener("mv-reset", (e) => {
      say(`Keepalive ping (${e.detail.reason})…`);
      e.detail.waitUntil(keepalive().then(() => say("Session extended by 45 s.")));
    });
    main.addEventListener("mv-reset-error", () => say("Keepalive failed: server unavailable."));
    main.addEventListener("mv-alarm", () => say("Alarm: 20 seconds left."));
    main.addEventListener("mv-expire", () => say("Session expired."));
    main.addEventListener("mv-signout", () => say("Signed out."));
    main.addEventListener("mv-sync", () => say("Extended from another tab."));

    document.getElementById("ht-signin").addEventListener("click", () => { main.restart(); say("Signed in again."); });
    document.getElementById("ht-restart").addEventListener("click", () => { main.restart(); say("Session restarted."); });
    document.getElementById("ht-ff").addEventListener("click", () => { main.deadline = Date.now() + 19_500; });
    document.getElementById("ht-kiosk-ff").addEventListener("click", () => {
      if (kiosk.state === "expired" || kiosk.state === "signed-out") kiosk.restart();
      kiosk.deadline = Date.now() + 25_500;
    });
    kiosk.addEventListener("mv-expire", () => setTimeout(() => kiosk.restart(), 4000));
    kiosk.addEventListener("mv-signout", () => setTimeout(() => kiosk.restart(), 4000));
  </script>
</div>

Referência cultural

Lost, J. J. Abrams, Damon Lindelof & Jeffrey Lieber (2004, série). Na escotilha, alguém precisa digitar o código antes que uma contagem regressiva chegue a zero, com um alarme nos últimos minutos, ou o sistema falha. Na interface, isso vira um interruptor do homem morto para a expiração de sessão: uma contagem regressiva discreta que vira alarme e precisa ser zerada deliberadamente antes que a sessão se perca.

API

Attributes

NameTipoDefaultDescription
durationtime ("15m", "90s", "1h30m", "500ms", ms number)15mSession length restored by every reset. The countdown starts on first connection (or from the deadline property if you set it).
warntime2mRemaining time under which the alarm phase starts: the chip turns amber and beacons, the alarm panel opens, the reset must be deliberate. Clamped to duration.
codestringConfirmation code the user must type (case and spaces ignored) before “Stay signed in” works during the alarm phase. For kiosks and high-security back offices; resetTimer() from code ignores it.
modalbooleanAlarm and expired panels open in a modal <dialog> (showModal, backdrop, focus trapped by the browser) instead of the anchored non-modal panel. Escape folds it; the chip stays in alarm.
soundbooleanSoft sine beeps via WebAudio when the alarm starts (2), at 30 s (1) and 10 s (3). Only after the user has interacted with the page (autoplay policy); never otherwise.
activity-resetbooleanReal pointer, key, wheel and scroll activity anywhere in the page silently resets the timer during the calm phase only (throttled to one reset every duration/10, between 5 s and 60 s). Never in the alarm phase: then the reset must be deliberate. Emits mv-reset with reason "activity" so you can ping your keepalive.
channelstringBroadcastChannel name shared by every tab of the same session: a reset (new deadline), an expiry or a sign-out in one tab applies to all of them. Ignored where BroadcastChannel is unsupported.
labelstringSessionLabel shown in the chip before the countdown.
placementbottom-end | bottom-start | bottom | top-end | top-start | topbottom-endPreferred side of the non-modal panel relative to the chip (flips and shifts to stay in the viewport).
data-stateactive | alarm | expired | signed-outSet by the component (styleable). data-critical is added in the last 10 seconds, data-pending while a keepalive promise is in flight.

Properties

NameTipoDescription
deadlinenumber (ms epoch) | DateAbsolute expiry time. Read it, or set it from your server’s real session expiry (also leaves the expired state). Setting it is broadcast to the other tabs.
remainingnumber (ms)Time left, recomputed from the deadline on every read (read-only).
state"active" | "alarm" | "expired" | "signed-out"Current phase (read-only).
pendingbooleanTrue while a reset waits for the promises passed to waitUntil() (read-only).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (keys: label, activeTitle, activeText, activeTextActivity, alarmTitle, alarmText, expiredTitle, expiredText, signedOutTitle, signedOutText, extend, stay, signOut, signIn, codeLabel ({code}), codeMismatch, error, timer ({time}), announceAlarm, announceLeft, announceExtended, announceRemote, announceExpired, announceSignedOut, expiredChip, signedOutChip). English defaults.

Methods

NameDescription
resetTimer({ reason? })Programmatic keepalive (reason defaults to "api"): emits the cancelable mv-reset, waits for waitUntil promises, applies the new deadline and broadcasts it. Returns Promise<boolean> (false if cancelled, failed, already pending or expired).
expire()Forces the expired state now (e.g. the server answered 401); emits mv-expire with reason "api" and broadcasts it.
restart()Starts a brand-new session (after signing in again): fresh deadline, calm phase, panel closed. No mv-reset event.
open() / close()Opens or folds the panel (the calm panel shows the countdown with “Extend session” and “Sign out”).

Events

NameDescription
mv-resetCancelable, before any reset. detail: { reason: "button" | "code" | "activity" | "api", remaining, waitUntil(promise) }. Call waitUntil() synchronously with your keepalive request: the button shows a busy state, expiry is held while it is in flight, a rejection keeps the old deadline and shows an error; a promise resolving to a Date or epoch ms uses that as the new deadline. preventDefault() refuses the reset.
mv-reset-errorA waitUntil promise rejected. detail: { reason, error }.
mv-alarmThe alarm phase started. detail: { remaining }.
mv-expireThe session is lost. detail: { reason: "timeout" | "api" | "remote" } (remote: another tab expired).
mv-signout“Sign out” was chosen (cancelable), or another tab signed out (detail.remote true, not cancelable). detail: { remote }.
mv-signinThe default “Sign in again” button of the expired panel was clicked (not emitted when you provide slot="expired" content). Call restart() once signed in.
mv-syncAnother tab moved the deadline (reset or server expiry). detail: { deadline }.

Content structure

NameDescription
expiredChildren with slot="expired" (a sign-in link, a button that opens your login dialog…) replace the default “Sign in again” button in the expired / signed-out panel.

CSS classes

NameDescription
mv-hatch-chipThe countdown chip (<button> toggling the panel): -chip-icon, -chip-label, .mv-hatch-reel, -chip-end (“Expired”).
mv-hatch-reel / mv-hatch-cell / mv-hatch-glyphRolling tabular digits: one clipped cell per character, glyphs roll out upward and in from below; separators have data-sep.
mv-hatch-panelAlarm / status panel (Popover API top layer, or inside .mv-hatch-dialog with modal): -head, -icon, -title, -text, -clock (big reel + -meter), -code, -error, -actions, -slot.
mv-hatch-dialogThe <dialog> used in modal mode (::backdrop styled with --mv-overlay).

CSS variables

NameDefaultDescription
--mv-hatch-warnvar(--mv-warning)Alarm-phase color: chip tint, beacon, meter, panel ring.
--mv-hatch-criticalvar(--mv-danger)Color of the last 10 seconds and of the expired state.

Accessibility

The chip is a real <button> (aria-expanded, aria-controls) described by a visually hidden role="timer" element (“14 minutes left in your session”; to the second during the alarm) whose implicit aria-live is off: screen readers read it on demand, it never chatters. Entering the alarm phase is announced once in an assertive live region (“Your session expires in 2 minutes. Choose Stay signed in to keep working.”), then at most at 60 s and 30 s (polite) and 10 s (assertive), skipping milestones already inside a short warn window. Focus moves to “Stay signed in” (or to the code field) when the alarm starts only if the user is not typing in a field and has not pressed a key in the last 1.5 s; otherwise the panel just appears. The panel is a labelled, described non-modal dialog placed right after the chip in the DOM (Tab reaches it naturally); Escape folds it back to the chip, the calm panel also closes on outside click, the alarm panel does not. modal uses a native <dialog> (focus containment, Escape folds it). The code field has a real <label> showing the code, aria-invalid and an error message linked with aria-describedby; Enter submits. While a keepalive is pending the button is aria-busy and aria-disabled; failures are announced assertively. After a reset, focus returns to the chip if it was inside the panel and “Session extended.” is announced politely; expiry is announced assertively and focus moves to the sign-in action (same typing rule). Colors always come with an icon and words (clock / alarm / lock, “Expired”). Reduced motion (OS or data-motion="reduce"): digits change without rolling, the beacon becomes a static ring, the meter jumps instead of sliding. Sound is opt-in and never plays before a user gesture.

Esta página foi traduzida com IA. Informar um problema de tradução