独家

Hatch <mv-hatch>

会话过期的“死人开关”:一个低调的倒计时标签(等宽数字像机械计数器一样滚动),必须有人在它归零前主动重置。

文化出处

《迷失》,J.J. 艾布拉姆斯、达蒙·林德洛夫和杰弗里·利伯(2004 年,剧集)。在地下舱里,必须有人在倒计时归零前输入密码,最后几分钟还会响起警报,否则系统就会失灵。在界面中,这变成了会话过期的失效保护开关:一个安静的倒计时会转为警报,必须在会话丢失之前主动重置。

工作原理

会话过期的“失效保护开关”(dead man’s switch):一个低调的倒计时标签(等宽数字像机械计数器一样滚动),必须有人在它归零前主动重置。低于警告阈值时,它会变成一个醒目但非模态的警报面板(或设置 modal 时变为 <dialog>),带有大号倒计时、逐渐耗尽的计量条,以及“Stay signed in” / “Sign out”;可选的确认码需要输入后才能重置(适用于自助终端、高安全场景),还可以启用柔和的 WebAudio 提示音。重置会经过一个可取消的 mv-reset 事件,其 waitUntil(promise) 会在你的保活接口响应期间显示等待状态,并处理失败情况。时间由 Date.now() 截止时间推算(不受后台标签页节流影响),重置和过期通过 BroadcastChannel 在多个标签页之间同步;用户活动只能在平静阶段静默延长会话,警报一旦出现就不再生效。

分类反馈
类型Web Component(<mv-hatch>)
状态稳定版
套件诚实的系统状态
同时安装button, 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

安装

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

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["hatch"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.css, components/input/input.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/hatch/hatch.js, components/hatch/hatch.css。

用法

快速开始,最小可运行标记:

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

标准标记,可在此基础上通过属性、data-* 和 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>
        Tallypoint
      </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>Acme 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="off" 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>

API

Attributes

Name类型DefaultDescription
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

Name类型Description
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.

本页面由 AI 翻译。报告翻译问题