独家

As Stored <mv-as-stored>

当表单背后的系统会改变数值形态时,在保存前准确展示每个值将如何被存储,而不是任由它被悄悄拉长或截断。

文化出处

普罗克拉斯提斯神话,希腊神话,见于普鲁塔克(《忒修斯传》)和西西里的狄奥多罗斯的记述(约公元前 1 世纪,神话)。普罗克拉斯提斯请旅人睡在一张床上,然后强迫他们与床完全吻合:矮的拉长,高的截短,事先却从不告诉他们。在 UI 中,系统固定的形状(字节上限、字符集、舍入、时区)会在保存前揭示出来:值按照将被存储的样子准确显示,每一次截短和拉长都会被点明,由用户选择修正、接受或换一种适配方式。

工作原理

当表单背后的系统会改变数值形态时,在保存前准确展示每个值将如何被存储,而不是任由它被悄悄拉长或截断。包裹一个表单,并为字段指定存储规则(data-as-stored="trim nfc max-bytes:20"):按字符、UTF-8 字节或 UTF-16 单元计算的长度限制,会替换、丢弃或音译字符的字符集(ASCII、Latin-1、BMP、无 emoji),trim / collapse / 大小写 / NFC/NFKC 规范化,去除变音符号(普通方式或 ICAO 护照拼写:ü → UE),删除撇号和连字符,数字和货币舍入(精确十进制、四舍五入、银行家舍入或向下取整),把电话号码转为 E.164(丢弃分机号,支持 35 个国家),时区转换和截断到日期,以及自定义规则。当存储值会与输入不同时,字段下方会出现一个低调的“Will be saved as”预览,显示存储值并就地标出每处改动(被替换的、被删除的、虚线之后被截掉的尾部),每处改动都用文字说明(“é” will become “e”、“2 emoji removed”、“Cut after “Kowalczyk-Brzęczysz”: “czykiewicz” will be lost. The limit is 20 bytes (UTF-8) and this is 30. ę counts as 2 bytes.”、“The day changes from Oct 15 to Oct 16, because the time is converted to UTC”),附带字节或字符计量条,并提供三种处理方式:Fix it myself(聚焦字段并选中确切的部分)、Accept(确认,或把存储值应用到字段)以及应用提供的替代方案。外观上的改动(空格、不可见的规范化)默认不提示,除非设置 show="all"。提交时,表单级摘要会列出所有将被改变形态的字段,并拦截一次保存,提供 Accept all and save;submit-stored 让表单发送的正是所显示的值。引擎是纯函数且不依赖 DOM(MvAsStored.transform(value, rules)),因此同一套规则可以在服务端和测试中运行。

分类表单
类型Web Component(<mv-as-stored>)
状态稳定版
套件不让用户流失的表单
同时安装button
Keywordsexclusive, culture, form, validation, normalization, truncation, maxlength, bytes, utf-8, charset, encoding, latin-1, ascii, emoji, diacritics, transliteration, i18n, names, phone, e164, rounding, currency, timezone, utc, preview, data-integrity

When to use

  • A form feeds a legacy system or database column that truncates, re-encodes or rounds what people type
  • People with international names (accents, apostrophes, CJK, long hyphenated names) must see how a system will spell them
  • A value is converted before storage (UTC day, E.164 phone, cents) and a silent shift would cause real mistakes
  • Client and server must agree on one normalization and the preview has to match what the backend really stores

Avoid when

  • The input must be formatted while typing (a card number, a phone mask) rather than previewed as stored → use Input instead
  • The value is invalid and must be fixed before anything is sent, not reshaped → use One at a Time instead
  • The problem is invisible or look-alike characters pasted into a field, not the storage rules → use Invisibles instead

安装

node scripts/add.mjs as-stored --out ./src/marvelous

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

复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/as-stored/as-stored.js, components/as-stored/as-stored.css。

用法

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

<mv-as-stored>
  <form>
    <label>First name <input name="first_name" value="Chloé" data-as-stored="charset:ascii:transliterate"></label>
    <button>Save</button>
  </form>
</mv-as-stored>

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

<div id="as-demo" style="width:min(100%,50rem);margin-inline:auto">
  <style>
    #as-demo { display:grid; gap:1rem; align-content:start }
    #as-demo .as-top { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
    #as-demo .as-top p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; flex:1 1 20rem }
    #as-demo .as-top strong { color:var(--mv-fg); font-weight:600 }
    #as-demo .as-switches { display:flex; gap:.5rem 1.125rem; flex-wrap:wrap }
    #as-demo .mv-choice { font-size:.8125rem }
    #as-demo .as-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem 1.25rem; align-items:start }
    #as-demo .as-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono); overflow-wrap:anywhere }
    #as-demo .as-legend { display:flex; align-items:center; gap:.5rem }
    #as-demo .mv-fieldset { margin:0 }
    @media (max-width:40rem) { #as-demo .as-row { grid-template-columns:minmax(0,1fr) } }
  </style>

  <div class="as-top">
    <p>This CRM syncs every customer to a <strong>legacy billing system</strong> with byte limits, an ASCII-only first name, cents and UTC days. Before saving, each field shows how it will really be stored.</p>
    <span class="as-switches">
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="as-all"> Show cosmetic changes</label>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="as-live"> Live summary</label>
    </span>
  </div>

  <!-- Main: a customer form synced to a legacy billing system -->
  <mv-as-stored id="as-main" time-zone="America/Los_Angeles" country="US" submit-stored>
    <form id="as-form" autocomplete="off">
      <fieldset class="mv-fieldset" data-variant="card">
        <legend class="as-legend">New customer <span class="mv-badge" data-variant="secondary" data-shape="pill">Syncs to billing</span></legend>
        <div class="mv-field-group">
          <div class="as-row">
            <div class="mv-field">
              <label class="mv-label" for="as-first">First name</label>
              <input class="mv-input" id="as-first" name="first_name" value="Chloé" data-as-stored="trim charset:ascii:transliterate">
            </div>
            <div class="mv-field">
              <label class="mv-label" for="as-last">Last name</label>
              <input class="mv-input" id="as-last" name="last_name" value="Kowalczyk-Brzęczyszczykiewicz" data-as-stored="trim nfc max-bytes:20">
            </div>
          </div>
          <div class="as-row">
            <div class="mv-field">
              <label class="mv-label" for="as-email">Work email</label>
              <input class="mv-input" id="as-email" name="email" type="email" value="[email protected]" data-as-stored="trim lowercase">
            </div>
            <div class="mv-field">
              <label class="mv-label" for="as-phone">Phone</label>
              <input class="mv-input" id="as-phone" name="phone" type="tel" value="(415) 555-0132 ext. 204" data-as-stored="phone:e164">
            </div>
          </div>
          <div class="as-row">
            <div class="mv-field">
              <label class="mv-label" for="as-company">Company</label>
              <input class="mv-input" id="as-company" name="company" value="Tarnwick  Traders " data-as-stored="trim collapse max-bytes:40">
            </div>
            <div class="mv-field">
              <label class="mv-label" for="as-limit">Credit limit</label>
              <input class="mv-input" id="as-limit" name="credit_limit" inputmode="decimal" value="$2,499.995" data-as-stored="currency:USD">
            </div>
          </div>
          <div class="as-row">
            <div class="mv-field">
              <label class="mv-label" for="as-start">Contract start (Pacific time)</label>
              <input class="mv-input" id="as-start" name="contract_start" type="datetime-local" value="2026-10-15T20:30" data-as-stored="timezone:UTC date">
            </div>
            <div class="mv-field">
              <label class="mv-label" for="as-note">Delivery note</label>
              <textarea class="mv-textarea" id="as-note" name="delivery_note" rows="2" data-as-stored="charset:no-emoji collapse trim">Leave at the front desk 📦 thanks! 🙏</textarea>
            </div>
          </div>
        </div>
        <div class="mv-fieldset-footer">
          <button type="reset" class="mv-button" data-variant="ghost">Reset</button>
          <button type="submit" class="mv-button">Save customer</button>
        </div>
      </fieldset>
    </form>
  </mv-as-stored>
  <p class="as-log" id="as-log" aria-live="polite">Stored values are sent as shown (submit-stored).</p>

  <!-- Variant: airline passenger names, printed on a boarding pass -->
  <mv-as-stored id="as-pax" summary="none" accept="apply">
    <form onsubmit="event.preventDefault()">
      <fieldset class="mv-fieldset" data-variant="card">
        <legend>Passengers <span class="mv-badge" data-variant="secondary" data-shape="pill">As on the boarding pass</span></legend>
        <div class="as-row">
          <div class="mv-field">
            <label class="mv-label" for="as-p1">Passenger 1 · full name</label>
            <input class="mv-input" id="as-p1" name="pax1" value="Seán O’Brien-Müller" data-as-stored="strip-diacritics:icao uppercase strip:'’- charset:ascii max:30">
          </div>
          <div class="mv-field">
            <label class="mv-label" for="as-p2">Passenger 2 · full name</label>
            <input class="mv-input" id="as-p2" name="pax2" value="王小明" data-as-stored="strip-diacritics:icao uppercase strip:'’- charset:ascii max:30">
          </div>
        </div>
      </fieldset>
    </form>
  </mv-as-stored>

  <script type="module">
    const main = document.getElementById("as-main");
    const pax = document.getElementById("as-pax");
    const form = document.getElementById("as-form");
    const log = document.getElementById("as-log");
    const say = (t) => { log.textContent = t; };

    // App-provided alternatives: a shorter legal name that fits, a passport spelling on file.
    main.alternatives = {
      last_name: ({ input, transform }) => {
        const first = input.split(/[-\s]/)[0];
        return first && first !== input && !transform(first).changed ? [{ label: `Use “${first}”`, value: first, description: "Keep only the first part of the name" }] : [];
      },
    };
    pax.alternatives = {
      pax2: ({ value }) => (value.includes("?") ? [{ label: "Use passport spelling “WANG XIAOMING”", value: "WANG XIAOMING" }] : []),
    };

    main.addEventListener("mv-reshape-accept", (e) => say(`Accepted: ${e.detail.label} → “${e.detail.value}”`));
    main.addEventListener("mv-reshape-summary", (e) => say(`Held the save: ${e.detail.fields.length} fields will be reshaped.`));
    pax.addEventListener("mv-reshape-accept", (e) => say(`Applied to the field: ${e.detail.label} → “${e.detail.value}”`));
    form.addEventListener("submit", (e) => {
      e.preventDefault();
      const data = Object.fromEntries(new FormData(form));
      say(`Saved · ${JSON.stringify(data)}`);
    });

    document.getElementById("as-all").addEventListener("change", (e) => { main.show = e.target.checked ? "all" : "change"; pax.show = main.show; });
    document.getElementById("as-live").addEventListener("change", (e) => { main.summary = e.target.checked ? "live" : "hold"; });
  </script>
</div>

API

Attributes

Name类型DefaultDescription
forstring (form id)The form to watch when it is not a child of the element.
showchange | loss | allchangeLowest severity that opens a preview: loss (value lost: cut, replaced, removed, rounded, time dropped), change (visible but lossless: case, phone format, time zone), all (also cosmetic: spaces, invisible normalization). Per field: data-as-stored-show.
summaryhold | live | noneholdhold: on submit, if any preview was not accepted, the submission is held once and a summary lists every reshaped field with Accept all and save. live: the summary is also shown permanently while anything will be reshaped. none: never hold, never summarize.
acceptkeep | applykeepkeep: Accept acknowledges the reshape (the field keeps what was typed and the preview folds to one line). apply: Accept writes the stored value into text-like fields (input/change events dispatched). Per field: data-as-stored-accept.
time-zoneIANA zone(browser zone)Zone in which wall-clock values (datetime-local) were typed, for the timezone rule. Per field: data-as-stored-zone.
countryISO 3166 alpha-2USDefault country for phone numbers typed without an international prefix. Per field: data-as-stored-country, or phone:e164:GB in the rules.
submit-storedbooleanOn the form’s formdata event, every reshaped field is sent as its stored value, so the server receives exactly what the preview showed.
delaynumber (ms)160Debounce between typing and updating the preview.
data-as-stored (on fields)rulesSpace-separated rules applied in order: trim, collapse, lowercase, uppercase, nfc, nfd, nfkc, nfkd, strip-diacritics[:icao], strip:<chars|punctuation|symbols|spaces|digits>, charset:<ascii|latin1|bmp|no-emoji>[:replace|drop|transliterate[:char]], max:<n> (characters = code points), max-bytes:<n> (UTF-8), max-utf16:<n>, round:<decimals>[:half-up|half-even|down], currency:<ISO code>[:mode], phone[:e164][:<country>], timezone:<target zone, e.g. UTC>, date[:day|month]. Also data-as-stored-label, -show, -accept, -zone, -country and -anchor (selector of the element the preview is appended to).

Properties

Name类型Description
rulesRecord<fieldName, string | Array<string | { name, apply(value, ctx), message?, severity?, kind? }>>Rules per field name, appended to data-as-stored. Custom rules return the new value (or { value, message, severity }); severity is cosmetic | change | loss.
alternatives(ctx) => Alternative[] | Record<fieldName, (ctx) => Alternative[]>Extra choices under a preview (max 3). ctx: { field, name, label, input, value, severity, changes, transform(v) }. Alternative: { label, value?, description?, action?(field) }; a value is written into the field, action runs your own code.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (label, savedAs, accepted, fix, accept, apply, review, empty, more, meter, desc, descAccepted, announce, announceMore, announceAccepted, summaryTitle, summaryTitleOne, summaryText, summaryLive, summaryDone, summaryDoneText, acceptAll, saveNow, reviewFirst, summaryAnnounce). English defaults.
formHTMLFormElement | nullThe watched form (read-only).
MvAsStored.transform(value, rules, options?)staticPure, DOM-free engine (also exported as transform). options: { timeZone, country }. Returns { input, value, changed, severity (none | cosmetic | change | loss), changes: [{ rule, kind, severity, message, detail? }], segments: [{ type: same | replace | add | remove | cut, text, was, start, end }], display (diff | plain), meter: { size, limit, unit } | null }. Also static parseRules, byteLength, measure(value, unit).

Methods

NameDescription
check()Re-evaluates every field (after setting values from code) and returns reshaped().
reshaped()Fields that will be reshaped: [{ field, name, label, input, value, severity, changes, accepted }].
stored(){ name: storedValue } for every tracked field, as the system will store them.
acceptField(fieldOrName)Accepts one reshape (emits mv-reshape-accept). Returns false if vetoed.
acceptAll()Accepts every pending reshape. Returns how many are still pending (vetoed).
review(fieldOrName)Focuses the field and selects the part that will be reshaped (Fix it myself).

Events

NameDescription
mv-reshapeCancelable. A field’s value will be stored differently (fired when the stored value or its changes differ from last time). detail: { field, name, label, input, value, severity, changes, accepted }. preventDefault() hides this preview so the app can present it its own way.
mv-reshape-clearA field that was reshaped will now be stored as typed. detail: { field, name, value }.
mv-reshape-acceptCancelable, before a reshape is accepted (button, acceptField, Accept all). detail: same as mv-reshape plus apply (boolean).
mv-reshape-alternativeCancelable, before an app-provided alternative is applied. detail: { field, name, alternative }.
mv-reshape-summaryCancelable, before a submission is held for the summary. detail: { fields, submitter }. preventDefault() lets the submission through.

CSS classes

NameDescription
mv-as-stored-previewPreview under a field (appended to its .mv-field, or after it): data-severity (cosmetic | change | loss), data-state (open | accepted). Parts: -head, -icon, -label, -meter (data-over), -value (data-display diff | plain), -changes, -change (data-kind, data-severity), -change-detail, -actions, -fix, -accept, -alt, -compact, -review.
mv-as-stored-mark / mv-as-stored-cutMarks inside the stored value: <mark data-kind="replace|add"> (underlined), <del data-kind="remove"> (struck, whitespace shown as ␣), the cut tail after a dashed line.
mv-as-stored-summaryForm-level summary inserted before the submit row (or inside [data-as-stored-summary]): data-severity (change | loss | done), data-held. Parts: -summary-head, -summary-title, -summary-text, -summary-list, -summary-item (data-accepted), -summary-field, -summary-value, -summary-reason, -summary-go, -summary-actions.

CSS variables

NameDefaultDescription
--mv-as-stored-lossvar(--mv-warning)Tone of previews where information is lost (cut, replaced, removed, rounded).
--mv-as-stored-changevar(--mv-info)Tone of previews where the value changes without loss (case, phone format, time zone).

Accessibility

Each preview is linked to its field with aria-describedby (added only while it is visible) through a visually hidden sentence that says everything in words: “Will be saved as “Kowalczyk-Brzęczysz”. Cut after …: “czykiewicz” will be lost.”; the visual stored value with its marks is aria-hidden so it is never read twice, and every change is also listed as real text under it, never conveyed by color alone (marks use underline, strike-through and a dashed cut line, which survive forced colors). While typing, announcements are debounced (1.1 s after the last change, only for the focused field, only when the stored result actually changed) in a polite live region: “Last name will be saved as “…”. Cut after … 1 more change is listed under the field.”. Fix it myself focuses the field and selects the exact characters that will be reshaped (the overflow, the replaced letter); Accept returns focus to the field and announces the result; the accepted line keeps a Review button (labelled with the field name) that reopens the preview. Held submissions move focus to the summary (a labelled region, tabindex -1) and announce it; each summary item has a Review button that goes to its field. All controls are native buttons in document order right after their field, so Tab reaches them naturally. Appear and fold animations are opacity-only and skipped under reduced motion (OS or data-motion="reduce"). Known limit: the change explanations (message, detail) come from the engine in English; strings translates the component's own labels, buttons and announcements, and a custom rule can supply its own message.

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