独家

Mast <mv-mast>

针对自我设定或安全敏感限制的非对称设置:收紧立即生效,放宽则需等待一段冷静期。

文化出处

奥德赛,荷马(约公元前 8 世纪,史诗)。奥德修斯让船员把自己绑在桅杆上,并命令他们在驶过塞壬时无视他的恳求:这是在头脑清醒时做出的承诺,在诱惑来临的那一刻无法撤销。在 UI 中,在冷静时设定的限制可以立即收紧,但只能在冷静期过后才能放宽,因此一时冲动请求的高风险更改需要等待,期间可以取消,并且可能需要再次确认。

工作原理

针对自我设定或安全敏感限制的不对称设置:收紧立即生效,放宽则需等待冷静期结束。包裹任意控件(数字输入框、滑块、下拉选择、分段控件、单选按钮、开关),并通过 safer="lower | higher | on | off" 或有序的值列表("private, internal, public")说明哪个方向更安全。更安全的更改会立即应用(mv-apply),并取消任何待处理的放宽请求;风险更高的更改会打开内联确认(“Raise your daily limit to $250?”,理由可选或必填),然后成为唯一的待处理请求,带倒计时圆环、以 en-US 格式显示的绝对截止时间以及剩余时间(“Your daily limit will rise to $250 on Sep 24, 10:12 AM · 19 h 42 min left · $100 stays in force until then · Cancel request”)。在此期间,生效中的值从不改变:控件会弹回到该值。到达截止时间时,请求会自动生效;若使用 confirm-after,则会等待再一次明确确认(可选在 confirm-window 之后失效)。新的风险更高的请求会重新开始计时,较小的请求则沿用原计时,状态也可以来自你的服务器(value、pending、until、pending-reason)。mv-request 可取消,并支持 waitUntil(promise),以便应用在倒计时开始前持久化该请求;时间基于绝对截止时间推算,因此后台标签页也不会产生偏差。

分类表单
类型Web Component(<mv-mast>)
状态稳定版
套件安全处理破坏性操作和机密信息
同时安装button, input
Keywordsexclusive, culture, settings, limits, cooling-off, cooldown, pending-change, delay, pre-commitment, responsible-gambling, spending-limit, screen-time, security, 2fa, retention, countdown, form

When to use

  • A user raises a self-imposed spending, deposit, loss or screen-time limit and the increase must wait out a cooling-off period
  • Responsible gambling or trading rules require limit increases to be delayed and confirmed, while decreases apply immediately
  • An admin weakens a security setting (turning off 2FA, making a repository public, removing a recovery method) and a delay buys time to react
  • Lowering a data-retention or backup policy would delete data for good, so the change should only take effect after a grace period

Avoid when

  • Both directions are equally safe: a plain control that saves on change is simpler and less frustrating
  • The risky action is a one-off operation to confirm now, not a setting that stays in force → use Two-Key instead
  • The delay must hold against a determined user: the server has to enforce it, the component only presents and orchestrates it

安装

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

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

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

用法

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

<mv-mast safer="lower" delay="24h">
  <label>Daily deposit limit <input type="number" value="100" min="0"></label>
</mv-mast>

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="ms-demo" style="width:min(100%,62rem);margin-inline:auto">
  <style>
    #ms-demo { display:grid; gap:1rem; align-content:start }
    #ms-demo .ms-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr)); gap:1rem; align-items:start }
    #ms-demo .ms-panel { display:grid; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #ms-demo .ms-head { display:flex; align-items:center; gap:.75rem; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #ms-demo .ms-logo { display:grid; place-items:center; flex:none; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
    #ms-demo .ms-logo svg { width:1.0625rem; height:1.0625rem }
    #ms-demo .ms-title { display:grid; gap:.0625rem; min-width:0; flex:1 }
    #ms-demo .ms-title h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #ms-demo .ms-title p { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
    #ms-demo .ms-row { padding:1rem 1.125rem }
    #ms-demo .ms-row + .ms-row { border-top:1px solid var(--mv-border) }
    #ms-demo .ms-field { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
    #ms-demo .ms-copy { display:grid; gap:.125rem; min-width:0; flex:1 1 11rem }
    #ms-demo .ms-label { font-size:.875rem; font-weight:560; color:var(--mv-fg) }
    #ms-demo .ms-sub { color:var(--mv-fg-muted); font-size:.75rem }
    #ms-demo .ms-range { display:grid; gap:.5rem; flex:1 1 100% }
    #ms-demo .ms-range-top { display:flex; justify-content:space-between; align-items:baseline; gap:1rem }
    #ms-demo .ms-range output { font-size:.8125rem; font-weight:600; font-variant-numeric:tabular-nums }
    #ms-demo .ms-range input { width:100% }
    #ms-demo mv-number-field { --mv-number-field-width:9.5rem }
    #ms-demo .ms-select { width:auto; min-width:9.5rem; flex:none }
    #ms-demo .ms-log { display:grid; gap:.5rem; padding:.875rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
    #ms-demo .ms-log h4 { display:flex; align-items:center; justify-content:space-between; margin:0; font-size:.75rem; font-weight:600; color:var(--mv-fg-muted); letter-spacing:.04em; text-transform:uppercase }
    #ms-demo .ms-log ol { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.75rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
    #ms-demo .ms-log li { display:flex; gap:.625rem; min-width:0 }
    #ms-demo .ms-log time { flex:none; color:var(--mv-fg-subtle) }
    #ms-demo .ms-log b { flex:none; font-weight:600; color:var(--mv-fg) }
    #ms-demo .ms-log span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
  </style>

  <div class="ms-grid">
    <!-- Personal finance: self-imposed spending and time limits -->
    <section class="ms-panel" aria-labelledby="ms-h-play">
      <header class="ms-head">
        <span class="ms-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="M3 17l5-5 4 4 8-8"/><path d="M15 8h5v5"/></svg></span>
        <span class="ms-title">
          <h3 id="ms-h-play">Trading limits</h3>
          <p>Harborline · limits you set for yourself</p>
        </span>
        <span class="mv-badge" data-variant="success"><span class="mv-badge-dot"></span>Protected</span>
      </header>

      <mv-mast id="ms-deposit" class="ms-row" safer="lower" delay="24h" confirm-after reason="optional" label="daily deposit limit" pending-reason="Moving savings for the quarterly rebalance">
        <div class="ms-field">
          <span class="ms-copy">
            <span class="ms-label" id="ms-deposit-l">Daily deposit limit</span>
            <span class="ms-sub">Across cards and bank transfers</span>
          </span>
          <mv-number-field id="ms-deposit-field" value="100" min="0" max="5000" step="25" format="currency" currency="USD"></mv-number-field>
        </div>
      </mv-mast>

      <mv-mast id="ms-time" class="ms-row" safer="lower" delay="20s" label="daily session time" unit="minute">
        <div class="ms-range">
          <span class="ms-range-top">
            <span class="ms-copy">
              <label class="ms-label" for="ms-time-input">Daily session time</label>
              <span class="ms-sub">Demo speed: 20-second cooling-off, applies on its own</span>
            </span>
            <output id="ms-time-out" for="ms-time-input">60 min</output>
          </span>
          <input id="ms-time-input" type="range" class="mv-slider" min="15" max="240" step="15" value="60">
        </div>
      </mv-mast>
    </section>

    <!-- Team workspace: security-sensitive settings -->
    <section class="ms-panel" aria-labelledby="ms-h-sec">
      <header class="ms-head">
        <span class="ms-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="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg></span>
        <span class="ms-title">
          <h3 id="ms-h-sec">Repository settings</h3>
          <p>acme-labs / design-tokens</p>
        </span>
        <span class="mv-badge" data-variant="outline">Admin</span>
      </header>

      <mv-mast id="ms-visibility" class="ms-row" safer="private, internal, public" delay="72h" confirm-after confirm-window="7d" reason="required" label="repository visibility" pending-reason="Share the tokens with every product team in the company">
        <div class="ms-field">
          <span class="ms-copy">
            <span class="ms-label" id="ms-vis-l">Visibility</span>
            <span class="ms-sub">Who can see the code and its history</span>
          </span>
          <mv-segmented aria-labelledby="ms-vis-l" value="private">
            <button value="private">Private</button>
            <button value="internal">Internal</button>
            <button value="public">Public</button>
          </mv-segmented>
        </div>
      </mv-mast>

      <mv-mast id="ms-2fa" class="ms-row" safer="on" delay="48h" label="two-factor sign-in for all members">
        <div class="ms-field">
          <span class="ms-copy">
            <label class="ms-label" for="ms-2fa-input">Require two-factor sign-in</label>
            <span class="ms-sub">Members without it lose access until they enroll</span>
          </span>
          <input id="ms-2fa-input" type="checkbox" role="switch" class="mv-switch" checked>
        </div>
      </mv-mast>

      <mv-mast id="ms-retention" class="ms-row" safer="higher" delay="7d" unit="day" label="audit log retention">
        <div class="ms-field">
          <span class="ms-copy">
            <label class="ms-label" for="ms-retention-input">Audit log retention</label>
            <span class="ms-sub">Older events are deleted for good</span>
          </span>
          <select id="ms-retention-input" class="mv-select ms-select">
            <option value="365" selected>365 days</option>
            <option value="180">180 days</option>
            <option value="90">90 days</option>
            <option value="30">30 days</option>
          </select>
        </div>
      </mv-mast>
    </section>
  </div>

  <section class="ms-log" aria-labelledby="ms-log-h">
    <h4 id="ms-log-h">Server log <span style="text-transform:none;letter-spacing:0;font-weight:500">mv-request · mv-apply · mv-cancel</span></h4>
    <ol id="ms-log" aria-live="off"></ol>
  </section>

  <script type="module">
    const root = document.getElementById("ms-demo");
    const log = document.getElementById("ms-log");
    const deposit = document.getElementById("ms-deposit");
    const visibility = document.getElementById("ms-visibility");
    const timeInput = document.getElementById("ms-time-input");
    const timeOut = document.getElementById("ms-time-out");
    const now = Date.now();
    const clock = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
    const labelOf = (el) => el.getAttribute("label");

    // State coming from the server: a raise requested earlier, and a change that finished cooling off.
    deposit.setAttribute("until", new Date(now + (19 * 60 + 42) * 60_000 + 30_000).toISOString());
    deposit.setAttribute("pending", "250");
    visibility.setAttribute("until", new Date(now - 3 * 3_600_000).toISOString());
    visibility.setAttribute("pending", "internal");

    const say = (event, text) => {
      const li = document.createElement("li");
      const t = document.createElement("time");
      t.textContent = clock();
      const b = document.createElement("b");
      b.textContent = event;
      const s = document.createElement("span");
      s.textContent = text;
      li.append(t, b, s);
      log.prepend(li);
      while (log.children.length > 4) log.lastElementChild.remove();
    };
    // The number field has no built-in label here: name its inner input after the row title.
    customElements.whenDefined("mv-number-field").then(() => setTimeout(() => {
      document.getElementById("ms-deposit-field").input?.setAttribute("aria-labelledby", "ms-deposit-l");
    }));

    say("sync", "Loaded 1 pending raise and 1 change ready to confirm.");

    const fmt = (el, v) => {
      if (el.id === "ms-deposit") return `$${v}`;
      if (el.id === "ms-time") return `${v} min`;
      if (el.id === "ms-retention") return `${v} days`;
      if (typeof v === "boolean") return v ? "on" : "off";
      return String(v);
    };

    // The app persists each request (a fake 500 ms save) before the countdown starts.
    root.addEventListener("mv-request", (e) => {
      const { value, until } = e.detail;
      const when = new Date(until).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
      e.detail.waitUntil(new Promise((r) => setTimeout(r, 500)).then(() => say("mv-request", `${labelOf(e.target)} → ${fmt(e.target, value)}, applies ${when}`)));
    });
    root.addEventListener("mv-apply", (e) => say("mv-apply", `${labelOf(e.target)} = ${fmt(e.target, e.detail.value)} (${e.detail.source})`));
    root.addEventListener("mv-cancel", (e) => say("mv-cancel", `${labelOf(e.target)}: request for ${fmt(e.target, e.detail.value)} ${e.detail.reason}`));

    // Keep the slider's readout in step with what the control shows.
    const syncTime = () => { timeOut.textContent = `${timeInput.value} min`; };
    for (const type of ["input", "change", "mv-draft", "mv-apply", "mv-request", "mv-cancel"]) {
      document.getElementById("ms-time").addEventListener(type, () => queueMicrotask(syncTime));
    }
  </script>
</div>

API

Attributes

Name类型DefaultDescription
saferlower | higher | on | off | comma-separated values (safest first)lowerWhich way is safer. lower / higher compare numbers (a limit, a retention period), on / off a switch or checkbox, and a list orders the control's values from safest to riskiest ("private, internal, public"). Values missing from the list count as the riskiest.
delaytime ("24h", "7d", "72h", "30m", "20s", "2w", ms number)24hCooling-off period before a riskier request becomes applicable. Shown in the rule as “a 24-hour cooling-off period”.
confirm-afterbooleanAt the end of the delay the request does not apply on its own: it turns into a “ready” card that needs one more explicit confirmation (as responsible-gambling rules often require). Without it the request applies automatically (mv-apply with source "timer").
confirm-windowtimeWith confirm-after: how long a ready request stays available. After that it lapses (mv-cancel with reason "lapsed") and the value in force stays.
reasonoptional | requiredAdds a reason field to the review step. required blocks the request until a reason is typed (announced error, aria-invalid). The reason travels in mv-request and is shown on the pending card.
labelstringNoun phrase used in every sentence and announcement (“daily deposit limit” → “Your daily deposit limit will rise to $250”). Defaults to the control's label text, first letter lowercased.
currency / unit / localestringNumber formatting for lower / higher values (Intl currency code, Intl unit such as minute or day, locale; default en-US). Read from the wrapped control when it carries the same attributes (mv-number-field).
valuestringThe value in force, when your server knows better than the control's markup. Changing it later syncs the control without any event; a pending request that is no longer riskier is dropped.
pendingstringA request already stored by your server (the value asked for). Ignored if it isn't riskier than the value in force. Removing it drops the request silently.
untilISO date | epoch msWhen the pending request becomes applicable. Without it, now + delay. A past date means it is ready (confirm-after) or applies at once.
pending-reasonstringReason stored with a server-provided request, shown on the pending card.
data-mast-controlmarker attributeOn a descendant: the element to treat as the control. Otherwise the first mv-number-field, mv-slider, mv-segmented, mv-scrub-field, mv-toggle-group, mv-wheel-picker, mv-combobox, input, select or textarea inside (a radio group is taken as a whole by name).
data-state / data-safer / data-busyset by the componentdata-state: idle | draft | cooling | ready. data-safer: the resolved mode (lower, higher, on, off, list). data-busy while a request waits for its waitUntil promises.

Properties

Name类型Description
valuenumber | boolean | string | nullThe value in force. Set it from your server (no event).
pendingnumber | boolean | string | nullThe value asked for by the pending request, or null. Setting it creates or drops a request without emitting events (server sync).
untilnumber (ms epoch)Deadline of the pending request, or null. Accepts a Date, ISO string or epoch ms.
remainingnumber (ms)Time left before the pending request is applicable, recomputed from until on every read (read-only).
state"idle" | "draft" | "cooling" | "ready"draft: a riskier change is under review, not sent yet; cooling: a request is waiting; ready: the delay is over and a confirmation is expected (read-only).
busybooleanTrue while mv-request waits for the promises passed to waitUntil() (read-only).
formatValue(value) => stringFormats values everywhere they are shown or announced (default: Intl number formatting, the option / segment / radio text, or On / Off).
stringsPartial<Record<string, string>> & { verbs? }Overrides for every visible text and announcement (rule, ruleConfirm (rule used with confirm-after), draftText, draftAmend, draftReplace, draftConfirm, reasonLabel, reasonOptional, reasonPlaceholder, reasonMissing, keep, pendingMeta, readyText, readyLapse, reasonShown, cancel, error, summaryPending, summaryReady, announceDraft, announceRequested, announceReady, announceTightened, announceApplied, announceCanceled, announceLapsed, on, off, fallbackLabel). strings.verbs overrides the direction wording: tighten, loosen, ask, will, stays, readyTitle, now, tightened, request, confirm, keep. English defaults, placeholders such as {label}, {value}, {current}, {date}, {delay}.

Methods

NameDescription
request(value, { reason? })Changes the value through the same rule as the control: safer applies now, riskier sends a request (mv-request with source "api"). Returns Promise<boolean>.
cancelRequest()Withdraws the pending request (mv-cancel with reason "api"). Always allowed: it is the safer way.
confirm()Applies a request whose delay is over (mv-apply with source "api"). Returns false if nothing is ready or the apply was vetoed.
discard()Throws away the change under review and puts the control back to the value in force.

Events

NameDescription
mv-requestCancelable, when the user sends a riskier change. detail: { value, previous, until, delay, reason, replaces (value of the request it replaces, or null), source: "user" | "api", waitUntil(promise) }. Persist it and call waitUntil() synchronously: the button shows a busy state, a rejection keeps the review open with an error, a promise resolving to a Date or epoch ms sets the deadline from your server. preventDefault() refuses it: the review closes and the control returns to the value in force.
mv-request-errorA waitUntil promise rejected. detail: { value, error }.
mv-applyCancelable, a value takes effect. detail: { value, previous, source: "instant" (safer change) | "timer" (delay over, no confirm-after) | "confirm" (user confirmed) | "api" }. preventDefault() vetoes it: an instant change snaps back, a pending one stays ready for an explicit confirmation.
mv-cancelThe pending request is gone. detail: { value, reason: "user" | "superseded" (a safer change applied) | "lapsed" (confirm-window elapsed) | "api" }.
mv-draftA riskier change opened or updated the review (detail.value), or the review closed, whether sent, discarded or superseded (detail.value null). detail.previous is the value in force. Handy to sync UI tied to the control.

Content structure

NameDescription
(content)Your label and control, left as they are. The component appends one .mv-mast-ui block after them. Listen to mv-apply / mv-request rather than to the control's own change events: the control briefly shows riskier values while they are under review.

CSS classes

NameDescription
mv-mast-ruleThe rule next to the control (“Lowering applies immediately. Raising takes effect after a 24-hour cooling-off period.”), linked to the control with aria-describedby.
mv-mast-cardReview and request cards; data-kind="draft | cooling | ready". Parts: -head, -badge, -ring (conic countdown, --_p = elapsed 0..1), -body, -title, -text, -meta, -left, -quote, -reason, -error, -actions.
mv-mast-statusShort-lived confirmation pill after a value took effect (“Lowered to $50”).

CSS variables

NameDefaultDescription
--mv-mast-draftvar(--mv-warning)Tone of the review card (a riskier change about to be requested).
--mv-mast-pendingvar(--mv-accent)Tone of the cooling-off card and its countdown ring.
--mv-mast-readyvar(--mv-success)Tone of the ready card and of the confirmation pill.

Accessibility

The control keeps its own role, name and keyboard behavior; the component adds two ids to its aria-describedby (the focusable part: the inner input of a number field, the radiogroup of a segmented control, every radio of a group): the rule (“Lowering applies immediately. Raising takes effect after a 24-hour cooling-off period.”) and a visually hidden summary of the current request (“Change pending: your daily deposit limit will rise to $250 on Sep 24, 10:12 AM.”), so the asymmetry is known before acting and the pending state is heard on every focus. A riskier change never moves focus: the review card appears right after the control in the DOM and a polite announcement says how long it will take and where to review it; Tab reaches its reason field and buttons, Enter in the reason field sends the request and Escape anywhere in the component throws the change away and returns focus to the control. Once sent, focus moves to “Cancel request” (the result of the action) and the request is announced politely with the full deadline (“Thursday, September 24 at 10:12 AM”); readiness, instant tightening, automatic applies, cancellations and lapses are announced politely too, saving errors assertively. The countdown text has role="timer" (implicitly aria-live off) with a spoken aria-label (“19 hours 42 minutes”), so it never chatters; the ring is decorative. A required reason uses a real label, aria-invalid and an error linked with aria-describedby. While a request is being saved the button is aria-busy and aria-disabled. States are never conveyed by color alone (icon, title and words: “will rise to”, “is ready”, “stays in force”). Deadlines are rendered as <time datetime> in absolute en-US time plus the time left. Reduced motion (OS or data-motion="reduce"): cards and the pill appear without sliding. Forced colors: cards keep a visible border and the ring uses Highlight on GrayText.

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