エクスクルーシブ

Why Not <mv-why-not>

説明可能な拒否です。理由と次の手段のない「ノー」はありません。

文化的リファレンス

審判、フランツ・カフカ(1925年、書籍)。ある男が逮捕され起訴されますが、罪状も、誰が裁いているのかも、どう上訴すればよいのかも最後まで知らされず、叩く扉はどれも理由を告げずに彼を拒みます。UI はそのちょうど逆です:拒否には必ず、その背後にあるルール、決定する人またはシステム、そして具体的な次の手段が伴います。ワンクリックでの申請や異議申し立ても含まれ、その進捗は常に見えるままです。

仕組み

説明可能な拒否:理由と次の手段を伴わない「ノー」はありません。拒否されたコントロール(Publish ボタン、メニュー項目、リンク、select)をラップすると、disabled ではなく aria-disabled でフォーカス可能なまま残り、角に小さな鍵、時計、または砂時計を表示し、ホバーやキーボードフォーカスで理由をちらりと見せ、クリック、Enter、Space では何も起こらないのではなく非モーダルなパネルを開きます。拒否されている間、アプリ自身のクリックハンドラー、ナビゲーション、フォーム送信は一切発火しません。コントロールがない場合は、同じ内容を 403 ページ、却下された申請、失敗した支払い、モデレーションによる削除向けの単独カードとして描画します。常に 4 つの問いに、アプリの言葉で(妥当なフォールバック付きで)答えます:何が拒否されたか、なぜか(このケースでのルール)、誰が決定するか(イニシャルと連絡先付きの人、チーム、ポリシー、または自動システム)、そして何ができるか(順序付けられた手段で、最初のものが推奨)。申請や異議申し立ての手段は waitUntil(promise) 付きのキャンセル可能な mv-request / mv-appeal イベントを経由し、その後ステータスをその場に表示します(「Requested 2h ago from Maya Chen · Usually answered within a day」、Cancel request ボタン付き)。grant() は短い成功リングとともに拒否を解除し、decline() は回答を表示します。「Not yet」(クォータ、本人確認、カウントダウンされて期限どおりに拒否が解除される until 日付)は「not allowed」と、色だけでなく言葉とアイコンで区別されます。トーンは落ち着いたままで、参照コードを表示でき、「Copy details」でサポート向けの要約をクリップボードにコピーできます。

カテゴリーフィードバック
タイプWeb Component(<mv-why-not>)
ステータス安定版
キット誠実なシステム状態
Keywordsexclusive, culture, permission, access-denied, forbidden, 403, disabled-button, aria-disabled, request-access, appeal, paywall, quota, moderation, payment-failed, explanation, rbac, empty-state, trust

When to use

  • A button or menu item is unavailable for this user and must say why and how to get access instead of greying out silently
  • A 403 page, rejected application, failed payment or removed post needs a clear reason, who decided and a way to appeal
  • People can request access or an approval in one click and should see that the request is pending and can be cancelled
  • A limit is temporary (quota, trial, verification) and users must know it lifts on its own and when

Avoid when

  • The control is available and only needs a short label on hover or focus → use Tooltip instead
  • Something cannot be enabled because its requirements loop back on each other or conflict → use Catch Loop instead
  • The user can act but is missing fields or struggling with a form, which calls for guidance rather than a refusal → use One at a Time instead

インストール

node scripts/add.mjs why-not --out ./src/marvelous

Marvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["why-not"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

コピーされるファイル(依存関係を含む):tokens/tokens.css、core/base.css、core/dismiss.js、core/dom.js、core/element.js、core/motion.js、core/observe.js、core/pointer.js、core/position.js、components/why-not/why-not.js、components/why-not/why-not.css。

使い方

クイックスタート、動作する最小限のマークアップ:

<mv-why-not why="Publishing needs the Editor role. You are a Viewer." decider="Maya Chen">
  <button>Publish</button>
  <button data-why-not-path="request">Request Editor access</button>
</mv-why-not>

基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:

<div id="wn-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #wn-demo { display:grid; gap:1rem; align-content:start }
    #wn-demo .wn-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 }
    #wn-demo .wn-bar { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; min-height:3.5rem; padding:.625rem 1rem .625rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #wn-demo .wn-crumbs { display:flex; align-items:center; gap:.375rem; min-width:0; font-size:.8125rem; color:var(--mv-fg-muted) }
    #wn-demo .wn-crumbs b { color:var(--mv-fg); font-weight:600 }
    #wn-demo .wn-crumbs svg { width:.875rem; height:.875rem; color:var(--mv-fg-subtle) }
    #wn-demo .wn-tools { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap; margin-inline-start:auto }
    #wn-demo .wn-body { padding:1.25rem 1.125rem 1.25rem }
    #wn-demo .wn-doc h3 { margin:0 0 .25rem; font-size:1.25rem; letter-spacing:-.015em }
    #wn-demo .wn-meta { margin:0 0 1rem; color:var(--mv-fg-muted); font-size:.75rem }
    #wn-demo .wn-doc p { margin:0 0 .75rem; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.6; max-width:34rem }
    #wn-demo .wn-doc ul { margin:0; padding-inline-start:1.125rem; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.7 }
    #wn-demo .wn-foot { display:grid; gap:.5rem; padding:.875rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #wn-demo .wn-controls { display:flex; align-items:center; gap:.625rem 1.125rem; flex-wrap:wrap }
    #wn-demo .mv-choice { font-size:.8125rem }
    #wn-demo .wn-log { margin:0; min-height:1.125rem; color:var(--mv-fg-subtle); font:.6875rem/1.5 var(--mv-font-mono); overflow-wrap:anywhere }
    #wn-demo .wn-standalone { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; align-items:stretch }
    #wn-demo .wn-caption { margin:.25rem 0 -.25rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
    @media (max-width:46rem) {
      #wn-demo .wn-standalone { grid-template-columns:minmax(0,1fr) }
    }
  </style>

  <!-- A docs editor seen by a Viewer: three refused actions, each explaining itself -->
  <section class="wn-app" aria-label="Page editor">
    <header class="wn-bar">
      <span class="wn-crumbs">
        Launch
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>
        <b>Q4 launch plan</b>
        <span class="mv-badge" data-variant="secondary">Viewer</span>
      </span>
      <span class="wn-tools">
        <!-- Pending: a request is already waiting for an answer -->
        <mv-why-not id="wn-invite" placement="bottom-end"
          what="You can’t invite guests to this page"
          why="People outside Tidewell need an admin’s approval before they can see internal pages."
          decider="Workspace admins" decider-type="team" decider-role="IT · Tidewell"
          response-time="within 4 hours" pending="request" pending-from="Workspace admins">
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm">Invite guest</button>
          <button data-why-not-path="request">Ask admins to approve a guest</button>
          <button data-why-not-path data-hint="Anyone at Tidewell can be added right away">Invite a teammate instead</button>
        </mv-why-not>

        <!-- Not yet: a quota that resets on its own -->
        <mv-why-not id="wn-export" kind="not-yet" placement="bottom-end"
          what="You’ve used this month’s PDF exports"
          why="The Starter plan includes 20 PDF exports per month, and all 20 have been used."
          decider="Starter plan" decider-type="policy" decider-role="20 exports per month">
          <button type="button" class="mv-button" data-variant="outline" data-size="sm">Export PDF</button>
          <button data-why-not-path data-hint="Unlimited exports, $12 per member per month">Upgrade to Team</button>
          <button data-why-not-path data-hint="Paste it into any doc or email">Copy the page as Markdown</button>
        </mv-why-not>

        <!-- Not allowed: a role the person doesn't have (opened on load) -->
        <mv-why-not id="wn-publish" open placement="bottom-end"
          what="You can’t publish this page"
          why="Publishing needs the Editor role in Launch. You are a Viewer."
          decider="Maya Chen" decider-role="Space owner · Launch" contact="[email protected]"
          response-time="within a day" reference="PERM-EDITOR-LAUNCH">
          <button type="button" class="mv-button" data-size="sm">Publish</button>
          <button data-why-not-path="request">Request Editor access</button>
          <button data-why-not-path data-hint="She gets a link to this draft">Ask Maya to publish it</button>
          <button data-why-not-path data-hint="Your changes stay private for now">Save as draft instead</button>
        </mv-why-not>
      </span>
    </header>

    <div class="wn-body">
      <article class="wn-doc">
        <h3>Q4 launch plan</h3>
        <p class="wn-meta">Draft · edited 12 minutes ago by Kenji Watanabe</p>
        <p>We ship the new billing dashboard to all Team and Business workspaces on October 20. The rollout runs in three waves over two weeks, starting with the 40 design partners who joined the beta in July.</p>
        <ul>
          <li>Oct 6 · docs freeze and support training</li>
          <li>Oct 20 · wave 1, design partners</li>
          <li>Oct 27 · wave 2, Team plans in North America and Europe</li>
          <li>Nov 3 · wave 3, everyone else</li>
        </ul>
      </article>

    </div>
    <footer class="wn-foot">
      <span class="wn-controls">
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wn-grant"> Maya grants Editor access</label>
        <label class="mv-choice"><input type="checkbox" class="mv-checkbox" id="wn-fail"> Make requests fail</label>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="wn-decline">Maya declines</button>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="wn-reset">Reset</button>
      </span>
      <p class="wn-log" id="wn-log" aria-live="polite">Hover, focus or click a refused action in the toolbar.</p>
    </footer>
  </section>

  <p class="wn-caption">Standalone, for pages and messages that are themselves the refusal</p>
  <div class="wn-standalone">
    <mv-why-not id="wn-payment" kind-label="Payment declined"
      what="Your payment didn’t go through"
      why="Your bank declined the $240.00 charge for the Team plan on the card ending in 4242. No money was taken."
      decider="Your card issuer" decider-type="system" decider-role="Declined with code 51, insufficient funds"
      reference="PAY-7Q2X-91">
      <button data-why-not-path data-hint="Your workspace stays active while you fix this">Use a different card</button>
      <button data-why-not-path data-hint="Worth a try once your bank confirms">Retry the payment</button>
      <a data-why-not-path href="#billing-help">Why banks decline payments</a>
    </mv-why-not>

    <mv-why-not id="wn-post" kind-label="Post removed"
      what="Your post was removed from Plant Swap"
      why="It links to a paid seed shop, and rule 3 of the group doesn’t allow selling or affiliate links."
      decider="Community moderators" decider-type="team" decider-role="Plant Swap · 4 volunteers"
      response-time="within 3 days" reference="MOD-20931">
      <button data-why-not-path data-hint="Your text and photos are kept">Edit and repost without the link</button>
      <a data-why-not-path href="#group-rules">Read the group rules</a>
      <button data-why-not-path="appeal">Appeal this decision</button>
    </mv-why-not>
  </div>

  <script type="module">
    const $ = (id) => document.getElementById(id);
    const publish = $("wn-publish");
    const invite = $("wn-invite");
    const exp = $("wn-export");
    const log = $("wn-log");
    const say = (text) => { log.textContent = text; };

    // The quota resets on the 1st of next month, at midnight.
    const now = new Date();
    exp.until = new Date(now.getFullYear(), now.getMonth() + 1, 1).toISOString();
    // The guest request was sent two hours ago.
    invite.pendingSince = new Date(Date.now() - 2 * 3600e3 - 4 * 60e3).toISOString();

    // A fake API: 700 ms round trip, optionally failing.
    const api = () => new Promise((resolve, reject) => {
      setTimeout(() => ($("wn-fail").checked ? reject(new Error("503")) : resolve()), 700);
    });

    const root = $("wn-demo");
    root.addEventListener("mv-why", (e) => say(`mv-why · ${e.detail.via} · “${e.detail.what}”${e.detail.attempts ? ` · attempt ${e.detail.attempts}` : ""}`));
    root.addEventListener("mv-request", (e) => { say(`mv-request · ${e.detail.label}…`); e.detail.waitUntil(api().then(() => say("Request delivered."))); });
    root.addEventListener("mv-appeal", (e) => { say(`mv-appeal · ${e.detail.reference}…`); e.detail.waitUntil(api()); });
    root.addEventListener("mv-withdraw", (e) => { say(`mv-withdraw · ${e.detail.type}…`); e.detail.waitUntil(api()); });
    root.addEventListener("mv-request-error", (e) => say(`mv-request-error · ${e.detail.type} · ${e.detail.error.message}`));
    root.addEventListener("mv-path", (e) => say(`mv-path · ${e.detail.label}`));
    root.addEventListener("mv-lift", () => say("mv-lift · the quota reset"));
    root.addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });

    $("wn-grant").addEventListener("change", (e) => {
      if (e.target.checked) { publish.grant(); say("Maya granted Editor access: Publish works now."); }
      else { publish.allowed = false; say("Editor access removed."); }
    });
    $("wn-decline").addEventListener("click", () => {
      $("wn-grant").checked = false;
      publish.allowed = false;
      publish.decline("Maya declined on " + new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" }) + ": “Only the launch team publishes until the Oct 6 docs freeze.”");
      say("Maya declined the request.");
    });
    $("wn-reset").addEventListener("click", () => {
      $("wn-grant").checked = false;
      $("wn-fail").checked = false;
      publish.allowed = false;
      publish.pending = null;
      publish.note = null;
      invite.pending = "request";
      invite.pendingSince = new Date(Date.now() - 2 * 3600e3 - 4 * 60e3).toISOString();
      say("Reset.");
    });
    // The Publish button only does its job once allowed.
    publish.querySelector(".mv-button").addEventListener("click", () => say("Published “Q4 launch plan”."));
  </script>
</div>

API

Attributes

NameタイプDefaultDescription
whatstringWhat was refused, as a title (“You can’t publish this page”). Fallback: “‘Publish’ isn’t available”, from the control’s label.
whystringThe rule, specific to this case (“Publishing needs the Editor role in Launch. You are a Viewer.”). Also read by screen readers on the control and shown in the peek. Fallback: a neutral sentence.
kindnot-allowed | not-yetnot-allowed (not-yet when until is set)Policy refusal or temporary one. Changes the eyebrow (“Not allowed” / “Not yet”), the icon (lock / clock) and the tone.
kind-labelstringReplaces the eyebrow text (“Payment declined”, “Post removed”).
untilISO date-time | ms epochWhen a temporary refusal lifts (a quota reset, a trial start). Shown as “in 6 days · Thu, Oct 1, 12:00 AM” and kept current; when it passes, the cancelable mv-lift fires and the control is allowed.
deciderstringWho decides: a person (“Maya Chen”), a team, a policy or a system. Fallback: “Your administrator”, or “Automatic” for a not-yet refusal with until.
decider-typeperson | team | policy | systempersonInitials avatar for a person, an icon otherwise, and a default role line (Team, Policy, Automated check).
decider-rolestringSecond line under the decider (“Space owner · Launch”, “Declined with code 51”).
contactemail | URL | textHow to reach the decider. An email becomes a mailto link, a URL a link. With no path declared, a “Contact {decider}” path is generated from it.
response-timestringHow long an answer usually takes (“within a day”): the hint of request and appeal paths and the second line of the pending status.
referencestringCase or error reference shown in the footer and included in Copy details (“MOD-20931”, “Error 403”).
notestringAn extra line under the facts, typically the decider’s answer after decline().
pendingrequest | appealA request or appeal is waiting for an answer: its path is replaced by the status and a withdraw button. Set by the component after a successful request, or server-rendered.
pending-sinceISO date-time | ms epochWhen the request was sent (“Requested 2h ago”). Set by the component; updated every minute.
pending-fromstringdeciderWho the request went to, when it differs from the decider.
allowedbooleanLifts the refusal: the control works normally, the badge, peek and panel are gone, ARIA is restored (a standalone card hides). Bind it to your permission check. Becoming allowed after an attempt announces it and rings the control once.
modepopover | inlinepopover (inline without a control)popover: a non-modal dialog anchored to the control in the top layer. inline: a disclosure right under the control, in the flow. Without a control the component is always a standalone card.
openbooleanReflects whether the explanation is open; set it to open on load (it never steals focus then).
placementtop | bottom | left | right, with -start | -endbottom-startPreferred side of the popover (flips and shifts to stay in view). The peek goes to the other vertical side.
peekhover | nonehoverSmall reason bubble on pointer hover (after 380 ms) and keyboard focus of the control. Purely visual: the same text is the control’s accessible description.
heading-level1-6Exposes the title as a heading of that level (use 1 on a full 403 page).
localeBCP 47en-USLocale for relative times and dates.
data-why-not-targetmarker attributeMarks the refused control when it is not the first control child.
data-why-not-path"" | request | appealOn a child <button> or <a href>: a way forward, moved into the panel in document order (don’t style it: rows are drawn by the component). request / appeal go through mv-request / mv-appeal and the pending state; a link request/appeal navigates unless waitUntil or preventDefault is used. data-hint adds a second line, data-keep-open keeps the popover open, data-id is passed in mv-path.
data-mode / data-kind / data-blocked / data-pending / data-busyset by the componentStyling hooks on the host. The control gets data-why-not-control while refused and data-why-not-granted during the success ring.

Properties

NameタイプDescription
controlElement | nullThe refused control (read-only).
pathsArray<{ label, hint?, type?: "request" | "appeal", href?, id?, keepOpen? }>Ways forward from data, appended after the declared ones (framework-friendly).
detailsstringPlain-text summary (what, why, availability, decider, contact, status, reference, date, page URL), ready for a support message (read-only).
attemptsnumberHow many times the refused control was activated (read-only), also in mv-why.
stringsPartial<Record<string, string>>Overrides for every default text and announcement (notAllowed, notYet, titleFor, whyFallback, whoLabel, optionsLabel, eta, pendingRequest, pendingAppeal, withdrawRequest, requestError, declined, contactPath, fallbackPath, copy, peekPointer, peekKeyboard, announce*…). English defaults.

Methods

NameDescription
show() / hide() / toggle()Open or close the explanation (popover and inline modes).
requestAccess() / appeal()Run the request or appeal lifecycle from code. Resolve to true once pending, false if cancelled or failed.
withdraw()Cancel the pending request or appeal (cancelable mv-withdraw with waitUntil).
grant()The decider said yes: clears pending and note and sets allowed.
decline(message?)The decider said no: clears pending and shows message (default “{decider} declined your request.”) as the note, announced politely.
copyDetails()Copies details to the clipboard; resolves true on success.
refresh()Re-scan the control and new path children after a framework replaced them.

Events

NameDescription
mv-whyThe explanation was opened (or a standalone card was seen). detail: { via: "pointer" | "keyboard" | "api" | "view", attempts, kind, what, why, reference, pending }. For analytics: which refusals people hit.
mv-requestCancelable. A request path was chosen. detail: { type, label, what, why, reference, kind, path, waitUntil(promise) }. Call waitUntil() synchronously with your API call: the row shows a spinner, a rejection shows an error and keeps the path. preventDefault() stops it.
mv-appealCancelable. Same detail and lifecycle as mv-request, for the appeal path.
mv-withdrawCancelable. The user cancels a pending request or appeal. detail: { type, waitUntil(promise) }.
mv-request-errorA waitUntil promise rejected. detail: { type: "request" | "appeal" | "withdraw", error }.
mv-pathAnother way forward was chosen. detail: { type: "action" | "link" | "contact", label, id, path }. The popover then closes (unless data-keep-open).
mv-liftCancelable. The until time passed; unless prevented, allowed is set. detail: { until }.

CSS classes

NameDescription
mv-why-not-panelThe explanation (role=dialog in popover mode, role=group otherwise): -head, -icon, -kind, -title, -facts (dl of .mv-why-not-row[data-row=why|until|who]), -note, -options, -label, -paths, -error, -foot, -ref, -copy, -close.
mv-why-not-path / mv-why-not-path-actionA way forward (<li>, data-primary on the recommended one, data-pending while its request waits) and its button or link: -path-icon, -path-label, -path-hint, -path-end.
mv-why-not-statusPending status (role=status): -status-icon, -status-title, -status-eta, .mv-why-not-withdraw.
mv-why-not-badgeIcon on the refused control’s corner (lock, clock or hourglass).
mv-why-not-peekHover / focus reason bubble (top layer, aria-hidden).

CSS variables

NameDefaultDescription
--mv-why-not-tonevar(--mv-fg-muted)Tone of a “not allowed” refusal (icon tile, eyebrow). Neutral on purpose: a refusal is not an error.
--mv-why-not-soonvar(--mv-info)Tone of a “not yet” refusal.
--mv-why-not-pendingvar(--mv-accent)Tone of a pending request or appeal.

Accessibility

The refused control is never disabled: disabled is removed (and removed again if a framework puts it back) and aria-disabled="true" is set, so it stays in the tab order and is announced as dimmed or unavailable. Its aria-describedby gains a visually hidden sentence (“Unavailable. Publishing needs the Editor role in Launch. You are a Viewer. Activate to see why and what you can do.”, with the availability date or the pending status when there is one); aria-haspopup="dialog", aria-expanded and aria-controls are set in popover mode. Click, Enter and Space are intercepted in the capture phase (no app handler, navigation or submit runs); custom role=button controls get the same Enter / Space handling, text fields become readonly and a select can’t open. Opening from the keyboard moves focus to the first way forward, opening with the pointer focuses the panel itself (a labelled non-modal dialog: title = what, description = why); the panel sits right after the control in the DOM, so Tab walks through it naturally, and the close button comes last. Escape closes it and returns focus to the control; tabbing out or clicking outside closes it without moving focus. The peek is aria-hidden (its text is already the description), appears on focus as well as hover, and Escape dismisses it. The ways forward are an ordered list labelled “What you can do”; each is a real button or link whose hint is its description. A request in flight sets aria-busy and aria-disabled on its row; success, failure, withdrawal, a decline and an allowed control are announced in polite or assertive live regions, and focus moves to Cancel request when the request row it was on is replaced by the status (and back to the path when withdrawn). The four answers are laid out as a description list. Refusals are never told by color alone: an eyebrow word (Not allowed, Not yet, or kind-label), an icon and the badge carry it; forced-colors mode keeps borders, badges and the recommended path visible. Reduced motion (OS or data-motion="reduce") stops the hourglass flip, the spinner and the success ring, and panels appear without sliding. Known limits: it guards one control (the element marked data-why-not-target, or else the first child control that is not a path); it blocks the user’s click, Enter and Space on that control but not the app’s own code (form.submit(), a shortcut handled elsewhere, a server call), so the real permission check must stay on the server; the popover uses the Popover API when available and falls back to a positioned panel in older browsers.

このページは AI で翻訳されています。翻訳の問題を報告