独家

Why Not <mv-why-not>

可解释的拒绝:任何“不”都附带理由和出路。

文化出处

《审判》,弗朗茨·卡夫卡(1925 年,小说)。一个人被逮捕并遭到起诉,却始终不知道罪名是什么、谁在审判他、如何上诉,他敲过的每一扇门都毫无解释地将他拒之门外。界面恰恰相反:拒绝从不会缺少背后的规则、做出决定的人或系统,以及具体的出路,包括一键提交的申请和申诉,其进度始终可见。

工作原理

可解释的拒绝:没有理由和出路,就不说“不”。包裹一个被拒绝的控件(Publish 按钮、菜单项、链接、下拉框),它会保持可聚焦,使用 aria-disabled 而不是 disabled,在角落显示一个小锁、时钟或沙漏图标,悬停或键盘聚焦时浮现原因;点击、按 Enter 或空格键时会打开一个非模态面板,而不是悄无声息地什么都不做;在被拒绝期间,应用自身的点击处理程序、导航和表单提交都不会触发。没有控件时,它会把相同内容渲染为独立卡片,用于 403 页面、被拒的申请、支付失败和内容审核移除。它始终回答四个问题,使用应用自己的措辞并提供合理的默认文案:拒绝了什么、为什么(针对此情况的规则)、由谁决定(带姓名首字母和联系方式的人、团队、政策或自动化系统)以及你能做什么(排好序的路径,第一条为推荐)。申请或申诉路径会经过可取消的 mv-request / mv-appeal 事件(支持 waitUntil(promise)),随后在原位显示其状态(“Requested 2h ago from Maya Chen · Usually answered within a day”)并提供 Cancel request 按钮;grant() 会以短暂的成功光环解除拒绝,decline() 显示答复。“Not yet”(配额、验证、倒计时结束即按时解除拒绝的截止日期)与 “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 翻译。报告翻译问题