エクスクルーシブ
One at a Time <mv-one-at-a-time>
エラーの壁を見せる代わりに、一度に 1 つのことだけを求める優先順位付きのフォーム検証です。
文化的リファレンス
刑事コロンボ、Richard Levinson & William Link(NBC)(1968年、シリーズ)。警部は用が済んだかのようにドアへ向かい、そこで振り返って「もう一つだけ」と言い、最も重要な質問をひとつずつ投げかけます。ここでは、フォームは送信できそうに見えますが、送信時にコンポーネントが振り返り、最も重要な問題をひとつだけ該当フィールドに紐づけて示し、その後で次の問題を示します。フォームを送信できるまでカウントダウンが続きます。
仕組み
エラーを一度に並べる代わりに、一度にひとつだけ求める優先順位付きのフォーム検証です。長いフォーム(オンボーディング、申し込み、決済、設定)を囲むか、for= でフォームを指定します。フォームには novalidate が付き、送信時にすべての問題が収集されます(validity と validationMessage から読み取るネイティブの制約検証、priority と blocking を持つアプリのルール、任意フィールドへの宣言的な提案)。問題は順位付けされ(ブロッキングが先、次に優先度、次に文書順。フィールドごとに 1 件)、最も重要なひとつだけが提示されます。提示は、そのフィールドの真下に挿入される落ち着いたプロンプトで、フィールドを指す矢印が付きます。「Before you continue · VAT number: Your company’s VAT number is needed to issue invoices in the EU.」、カウンター(「2 more after this (1 optional)」)、進捗の点が表示されるため、終わりのないループのようには感じられません。フィールドはスクロールして表示されフォーカスされ、aria-invalid と aria-describedby が付与され、修正されるとすぐにプロンプトがライブで「Looks good」に変わります。Enter または Continue で再送信します。ブロッキングの問題が残っていなければフォームは送信され(mv-all-clear の後、アプリ自身の送信ハンドラーが実行されます)、残っていればプロンプトは次のフィールドに移ります。select、チェックボックス、ラジオは選択すると自動で次へ進みますが、最終的な送信には必ず意図的な操作が必要です。ブロッキングでない提案は最後に「Skip, I’ll add it later」とともに表示され、「Show everything」はパワーユーザー向けに残りの項目を一覧表示します(各項目からそのフィールドへジャンプ)。ルールは任意のフィールドを読める通常の同期関数で、プロンプトは複数カラムの行から抜け出すためグリッドを崩しません。閉じた <details> 内に隠れたフィールドは展開され、mv-present(キャンセル可能)を使えば、アプリはウィザードのステップを開いたり、独自の方法で問題を提示したりできます。
| カテゴリー | フォーム |
|---|---|
| タイプ | Web Component(<mv-one-at-a-time>) |
| ステータス | 安定版 |
| キット | ユーザーを取りこぼさないフォーム |
| 同時にインストールされるもの | button |
| Keywords | exclusive, culture, form, validation, form-validation, constraint-validation, error-summary, one-at-a-time, prioritized, focus-management, onboarding, checkout, application-form, suggestion, skippable, accessibility, novalidate, submit |
When to use
- A long onboarding, application or checkout form fails on submit and a list of ten red errors would discourage people
- Some problems matter more than others (a tax ID needed to invoice, a legal consent) and must be asked first
- Optional but valuable fields (phone, logo, team size) deserve a gentle, skippable ask right before the form is sent
- Validation messages should be written as one clear request at a time, anchored to the field, with native constraints kept
Avoid when
- The form is short (login, newsletter): inline field errors are faster to scan than a one-by-one flow → use Field instead
- The value is valid but the content probably forgot something (an attachment mentioned, a leftover placeholder) → use Forgot instead
- People need hints while filling the form in, before they ever submit it → use Insist instead
インストール
node scripts/add.mjs one-at-a-time --out ./src/marvelousMarvelous UI の MCP サーバーを使う AI エージェント:install_components({ slugs: ["one-at-a-time"], 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/focus.js, core/motion.js, core/observe.js, components/one-at-a-time/one-at-a-time.js, components/one-at-a-time/one-at-a-time.css。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-one-at-a-time>
<form>
<label>Email <input name="email" type="email" required></label>
<label>VAT number <input name="vat" required data-one-at-a-time-message="Your VAT number is needed to issue invoices."></label>
<button>Continue</button>
</form>
</mv-one-at-a-time>基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<div id="oat-demo" style="width:min(100%,62rem);margin-inline:auto">
<style>
#oat-demo .oat-layout { display:grid; grid-template-columns:minmax(0,1fr) 16.5rem; gap:1.25rem; align-items:start }
#oat-demo .oat-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#oat-demo .oat-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
#oat-demo .oat-head h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#oat-demo .oat-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#oat-demo form { display:grid; gap:1.125rem; padding:1.25rem }
#oat-demo .oat-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem }
#oat-demo .oat-row[data-cols="3"] { grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,1fr) }
#oat-demo .oat-foot { display:flex; align-items:center; justify-content:flex-end; gap:.5rem; margin:.25rem -1.25rem -1.25rem; padding:.875rem 1.25rem; border-top:1px solid var(--mv-border); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#oat-demo .oat-foot .oat-note { order:-2; margin-right:auto; color:var(--mv-fg-muted); font-size:.75rem }
#oat-demo .oat-foot [formnovalidate] { order:-1 }
#oat-demo .oat-done { display:none; align-items:center; gap:.75rem; margin:1.25rem 1.25rem 0; padding:.75rem 1rem; border:1px solid color-mix(in oklab, var(--mv-success) 35%, var(--mv-border)); border-radius:var(--mv-radius-lg); background:color-mix(in oklab, var(--mv-success) 7%, var(--mv-surface)); font-size:.8125rem }
#oat-demo .oat-done[data-show] { display:flex }
#oat-demo .oat-done svg { flex:none; width:1.125rem; height:1.125rem; color:var(--mv-success) }
#oat-demo .oat-side { position:sticky; top:1rem; display:grid; gap:1rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
#oat-demo .oat-side h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; font-size:.8125rem; font-weight:600 }
#oat-demo .oat-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#oat-demo .oat-queue { display:grid; gap:.375rem; margin:0; padding:0; list-style:none }
#oat-demo .oat-queue li { display:flex; align-items:baseline; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
#oat-demo .oat-queue li b { color:var(--mv-fg); font-weight:500 }
#oat-demo .oat-queue li[data-now] b { color:var(--mv-accent) }
#oat-demo .oat-queue li span { margin-left:auto; white-space:nowrap }
#oat-demo .oat-queue .oat-empty { color:var(--mv-fg-subtle) }
#oat-demo .oat-log { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.6875rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
#oat-demo .oat-log li { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#oat-demo .oat-log li b { color:var(--mv-fg); font-weight:500 }
#oat-demo .oat-sep { height:1px; background:var(--mv-border) }
#oat-demo .oat-actions { display:flex; flex-wrap:wrap; gap:.5rem }
#oat-demo .mv-choice { font-size:.8125rem }
#oat-demo .oat-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:52rem) {
#oat-demo .oat-layout { grid-template-columns:minmax(0,1fr) }
#oat-demo .oat-side { position:static }
}
@media (max-width:34rem) {
#oat-demo .oat-row, #oat-demo .oat-row[data-cols="3"] { grid-template-columns:minmax(0,1fr) }
}
</style>
<div class="oat-layout">
<section class="oat-card" aria-labelledby="oat-title">
<div class="oat-head">
<div>
<h3 id="oat-title">Billing details</h3>
<p>Kestrel Labs workspace · Step 3 of 3</p>
</div>
<span class="mv-badge" data-variant="secondary">Pro · $240 / month</span>
</div>
<div class="oat-done" id="oat-done" role="status">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9.5"/><path d="m8 12.5 2.8 2.8L16.5 9.5"/></svg>
<span id="oat-done-text">Workspace created. The first invoice goes out on October 1, 2026.</span>
</div>
<mv-one-at-a-time id="oat">
<form id="oat-form" aria-labelledby="oat-title">
<div class="mv-field">
<label class="mv-label" for="oat-company" data-required>Company name</label>
<input class="mv-input" id="oat-company" name="company" value="Kestrel Labs GmbH" required autocomplete="organization">
</div>
<div class="mv-field">
<label class="mv-label" for="oat-email" data-required>Billing email</label>
<input class="mv-input" id="oat-email" name="email" type="email" value="dana.whitfield.example.com" required autocomplete="email" spellcheck="false">
</div>
<div class="oat-row">
<div class="mv-field">
<label class="mv-label" for="oat-country" data-required>Country</label>
<select class="mv-select" id="oat-country" name="country" required autocomplete="country-name">
<option value="">Select a country</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="DE" selected>Germany</option>
<option value="FR">France</option>
<option value="NL">Netherlands</option>
<option value="GB">United Kingdom</option>
<option value="JP">Japan</option>
</select>
</div>
<div class="mv-field">
<label class="mv-label" for="oat-vat">VAT number</label>
<input class="mv-input" id="oat-vat" name="vat" placeholder="DE123456789" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="oat-row" data-cols="3">
<div class="mv-field">
<label class="mv-label" for="oat-street" data-required>Street address</label>
<input class="mv-input" id="oat-street" name="street" value="Torstraße 128" required autocomplete="street-address">
</div>
<div class="mv-field">
<label class="mv-label" for="oat-zip" data-required>Postal code</label>
<input class="mv-input" id="oat-zip" name="zip" value="10119" required pattern="[0-9A-Za-z \-]{3,10}" autocomplete="postal-code" data-one-at-a-time-message="Use the postal code printed on your company registration.">
</div>
<div class="mv-field">
<label class="mv-label" for="oat-city" data-required>City</label>
<input class="mv-input" id="oat-city" name="city" value="Berlin" required autocomplete="address-level2">
</div>
</div>
<div class="mv-field">
<label class="mv-label" for="oat-phone" data-optional="optional">Phone</label>
<input class="mv-input" id="oat-phone" name="phone" type="tel" placeholder="+49 30 1234567" autocomplete="tel"
data-one-at-a-time-suggest="Your onboarding specialist can call you to set up SSO and invite your team.">
</div>
<label class="mv-choice">
<input type="checkbox" class="mv-checkbox" id="oat-terms" name="terms" required
data-one-at-a-time-label="Terms of Service"
data-one-at-a-time-message="Accept the Terms of Service and the Data Processing Agreement to create the workspace.">
I agree to the Terms of Service and the Data Processing Agreement
</label>
<div class="oat-foot">
<span class="oat-note">You can change this later in Settings.</span>
<!-- The primary button comes first in the DOM: it is the one Enter submits with. -->
<button type="submit" class="mv-button">Create workspace</button>
<button type="submit" class="mv-button" data-variant="ghost" formnovalidate>Save draft</button>
</div>
</form>
</mv-one-at-a-time>
</section>
<aside class="oat-side" aria-label="Validation monitor">
<h4>Still to check <span class="mv-badge" data-variant="secondary" data-shape="pill" id="oat-count">-</span></h4>
<ol class="oat-queue" id="oat-queue"><li class="oat-empty">Nothing yet</li></ol>
<div class="oat-sep"></div>
<div style="display:grid;gap:.5rem">
<span class="oat-k">Events</span>
<ol class="oat-log" id="oat-log"><li>Press <b>Create workspace</b></li></ol>
</div>
<div class="oat-sep"></div>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="oat-auto" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Move on after a choice</span></span>
</label>
<div class="oat-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="oat-all">Show everything</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="oat-reset">Start over</button>
</div>
</aside>
</div>
<p class="oat-hint">Fix the field in the prompt, then press Enter or Continue · Esc hides the prompt · the VAT rule only applies to EU countries</p>
<script type="module">
const oat = document.getElementById("oat");
const form = document.getElementById("oat-form");
const $ = (id) => document.getElementById(id);
const EU = new Set(["DE", "FR", "NL"]);
// App rules: a priority above the native checks, because invoices cannot be issued without it.
oat.rules = [
{
id: "vat",
field: "vat",
priority: 10,
message: "Your company’s VAT number is needed to issue invoices in the EU.",
validate: (value, { get }) => !EU.has(get("country")) || /^[A-Z]{2}[0-9A-Z]{8,12}$/.test(value.replace(/\s+/g, "").toUpperCase()) || (value.trim() ? "That doesn’t look like an EU VAT number (e.g. DE123456789)." : false),
},
];
await customElements.whenDefined("mv-one-at-a-time");
let fresh = true;
const log = (name, text) => {
const list = $("oat-log");
if (fresh) { list.replaceChildren(); fresh = false; }
const li = document.createElement("li");
const b = document.createElement("b");
b.textContent = name;
li.append(b, ` ${text}`);
list.prepend(li);
while (list.children.length > 5) list.lastElementChild.remove();
};
const renderQueue = () => {
const issues = oat.issues;
const q = $("oat-queue");
$("oat-count").textContent = issues.length ? `${issues.length} left` : "clear";
$("oat-count").dataset.variant = issues.length ? "secondary" : "success";
if (!issues.length) {
q.replaceChildren(Object.assign(document.createElement("li"), { className: "oat-empty", textContent: "Nothing left to check" }));
return;
}
q.replaceChildren(...issues.map((i, n) => {
const li = document.createElement("li");
if (n === 0) li.dataset.now = "";
const b = document.createElement("b");
b.textContent = i.label;
const s = document.createElement("span");
s.textContent = n === 0 ? "asking" : i.blocking ? "needed" : "optional";
li.append(b, s);
return li;
}));
};
oat.addEventListener("mv-present", (e) => {
log("mv-present", `${e.detail.issue.label} · ${e.detail.remaining} more`);
queueMicrotask(renderQueue);
});
oat.addEventListener("mv-skip", (e) => log("mv-skip", e.detail.issue.label));
oat.addEventListener("mv-all-clear", (e) => {
log("mv-all-clear", e.detail.skipped.length ? `${e.detail.skipped.length} skipped` : "submitting");
queueMicrotask(renderQueue);
});
form.addEventListener("submit", (e) => {
e.preventDefault(); // demo: no real request
if (e.submitter?.formNoValidate) { log("submit", "draft saved, not checked"); return; }
$("oat-done").dataset.show = "";
log("submit", "workspace created");
});
$("oat-auto").addEventListener("change", (e) => { oat.manual = !e.target.checked; });
$("oat-all").addEventListener("click", () => oat.showAll());
$("oat-reset").addEventListener("click", () => {
// A real reset also clears the browser's “user interacted” flags (and the component's state).
form.reset();
delete $("oat-done").dataset.show;
fresh = true;
log("reset", "form restored");
setTimeout(() => { oat.present({ focus: false, scroll: false }); renderQueue(); });
});
// Show the mechanic right away, without moving focus or scrolling the page.
oat.present({ focus: false, scroll: false });
renderQueue();
</script>
</div>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
for | id | Checks the form with this id instead of the form inside (or around) the element. Without it, the first <form> inside is used, then the closest ancestor form; a form rendered later is picked up when it appears. | |
manual | boolean | false | Turns off moving on by itself after a choice. By default, once a select, checkbox, radio, file, range or color field (or one marked data-one-at-a-time-advance) fixes the current issue, the next one is presented after about half a second; the last issue never submits on its own either way. |
data-state | asking | Set on the element while a prompt is open (styleable). | |
data-one-at-a-time-priority | number | 0 | On a field: rank of its native issue or suggestion. Higher is asked first; blocking issues always come before suggestions, ties follow document order. |
data-one-at-a-time-message | string | On a field: request shown when it fails native validation, instead of the browser's validationMessage (write it as one clear ask). | |
data-one-at-a-time-label | string | On a field: name shown as the prompt title (default: aria-labelledby, aria-label, <label> text without its controls, the legend of a radio group, placeholder, then name). | |
data-one-at-a-time-suggest | string | On an optional field: when it is empty (unchecked, no file…) it becomes a non-blocking suggestion with this text, asked after every blocking issue and skippable. | |
data-one-at-a-time-anchor | marker attribute | On a wrapper around a field: the prompt is inserted after it (default: .mv-field, the fieldset of a checkbox or radio group, a wrapping <label>, else the field; the component then climbs out of any grid or flex row shared with other fields). | |
data-one-at-a-time-ignore | marker attribute | On a field or a container: excluded from the checks (native and rules). | |
data-one-at-a-time-skip | marker attribute | On a submit button: submits without any check, like formnovalidate (which is honored too), e.g. Save draft. | |
data-one-at-a-time-advance | marker attribute | On a field (e.g. a custom picker): a committed change that fixes the current issue moves on by itself, like a select. | |
data-one-at-a-time | blocking | suggestion | Set by the component on the field currently asked about (styleable), removed afterwards. |
Properties
| Name | タイプ | Description |
|---|---|---|
rules | Array<{ id?, field?, validate(value, ctx), message?, priority?, blocking?, label? }> | App rules. field: a name, a selector or an element of the form (omit it for a form-level rule anchored above the submit button; a rule whose field is not in the DOM is skipped). validate(value, { field, form, get(name) }) returns false or a string (the message) when there is a problem, anything else when it is fine; value is the string, checked state, radio group value, array (multi-select) or FileList. message: string or (ctx) => string. priority: number (default 0). blocking: default true; false makes it a skippable suggestion. A throwing rule never blocks. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (keys: eyebrow, resolved, optional, counter ({n}), counterOptional ({n}, {k}), last, continue, submit, skip, showAll ({n}), hideAll, listLabel, current, announce ({label}, {message}, {counter}), announceResolved ({label}, {action}), announceStill ({label}, {message})). English defaults. |
form | HTMLFormElement | null | The form being checked (read-only). |
current | { id, field, label, message, blocking, priority, source } | null | The issue being asked about (read-only). source: "native" | "rule" | "suggestion". |
issues | Array<issue> | Every issue left at the last evaluation, ranked, the one asked about first (read-only). |
skipped | string[] | Ids of the suggestions skipped for this form session (read-only). Cleared by reset() and on form reset. |
manual | boolean | Mirrors the attribute. |
Methods
| Name | Description |
|---|---|
check() | Evaluates the form without presenting anything and returns the ranked issues (skipped suggestions left out). |
present({ focus?, scroll? }) | Asks about the most important issue now, without a submit (a Review step, a server error you mapped to a rule, a demo). Both options default to true; with focus: false nothing is announced either. Returns the issue presented, or null when all is clear. |
showAll() | Opens the “Show everything” list in the prompt (presenting first if needed). Returns false when there is nothing to show. |
dismiss() | Hides the prompt and restores the field's ARIA; the next submit starts over. Escape does the same from the field or the prompt. |
reset() | Forgets skipped suggestions and hides the prompt (done automatically on the form's reset event). |
Events
| Name | Description |
|---|---|
mv-present | Cancelable, before an issue is presented. detail: { field (null for a form-level rule), remaining (issues left after this one), issue, issues (ranked), submitter }. Open the wizard step or tab that holds the field here (focus waits a frame for hidden fields); preventDefault() keeps the submit held but lets you present the issue your own way (no prompt, no ARIA changes). |
mv-skip | A suggestion was skipped (“Skip, I’ll add it later”, or submitting again past it). detail: { field, id, issue }. |
mv-all-clear | Nothing blocking and no suggestion left: emitted right before the submission goes on to the app's own submit handlers. detail: { form, submitter, skipped }. |
Content structure
| Name | Description |
|---|---|
(content) | A <form> (or nothing, with for=). The prompt is inserted inside the form, after the anchor of the field, and removed afterwards; two visually hidden live regions are appended to the element. |
CSS classes
| Name | Description |
|---|---|
mv-one-at-a-time-prompt | Inserted wrapper (role=group, labelled by its eyebrow and title). data-kind="blocking | suggestion", data-side="below | above" (above for form-level rules), data-resolved once the issue is fixed. Spans every grid column. |
mv-one-at-a-time-card | The visual card with its caret (--_caret-x, measured). Parts: -head, -icon, -eyebrow, -tag (“Optional”), -count, -dots (i[data-state=done|current|todo]), -title, -message, -actions, -continue, -skip, -all-toggle. |
mv-one-at-a-time-list / -item | “Show everything” list: one button per issue (-item-index, -item-label, -item-message), aria-current on the one asked about. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-one-at-a-time-color | var(--mv-accent) | Tint of a blocking prompt: border, caret, icon, current dot. Deliberately not the danger color: it is a request, not a scolding. |
--mv-one-at-a-time-suggest-color | var(--mv-info) | Tint of a suggestion prompt. |
--mv-one-at-a-time-resolved-color | var(--mv-success) | Tint once the current issue is fixed, and of the done dots. |
Accessibility
The prompt is a labelled group (eyebrow + field name) inserted right after the field in the DOM, so reading and Tab order stay natural: field, Continue (or Skip), Show everything, next field. The field asked about gets aria-invalid="true" (blocking issues only, removed live as soon as it is fixed, the previous value is restored afterwards) and the prompt's message is added to its aria-describedby; for a radio group every radio is wired. Focus moves to the field (the checked radio or the first one, the first focusable part of a form-associated custom element, or Continue for a form-level rule), with preventScroll, while the component scrolls the field and its prompt into view together (instant under reduced motion). Each issue is announced once in an assertive live region, a beat after focus moves: “Before you continue: VAT number. Your company’s VAT number is needed to issue invoices in the EU. 2 more after this.”; fixing it is announced politely once (“VAT number looks good. Press Enter or Continue to continue.”), and submitting again while it is still unanswered keeps focus on it with a polite “Still needed: …” and a soft ring (no shake). Everything works from the keyboard: Enter in a field submits again, Escape from the field or the prompt hides it, the “Show everything” toggle is a real button with aria-expanded and aria-controls, and its list items are buttons (aria-current on the one being asked) that move focus to their field. State is never color-only: an icon (a turn-back arrow, a bulb for suggestions, a check once fixed), the words “Before you continue” / “Looks good”, an “Optional” tag and a text counter carry it; the progress dots are aria-hidden. The final submission always takes a deliberate action (Enter, Continue or the submit button), never a checkbox tick. Reduced motion (OS or data-motion="reduce"): no height, slide or ring animation. Forced colors: system colors for the card, caret, icon and current item.