Exclusifbêta

Why Not <mv-why-not>

Refus explicable : pas de « non » sans raison ni issue. Enveloppez un contrôle refusé (un bouton Publish, un élément de menu, un lien, un select) : il reste focalisable avec aria-disabled au lieu de disabled, affiche un petit cadenas, une horloge ou un sablier dans son coin, laisse entrevoir sa raison au survol ou au focus clavier et, au clic, sur Entrée ou Espace, ouvre un panneau non modal au lieu de ne rien faire en silence ; les gestionnaires de clic de l’application, sa navigation et ses soumissions de formulaire ne se déclenchent jamais tant que le refus est actif. Sans contrôle, il affiche le même contenu sous forme de carte autonome pour les pages 403, les candidatures refusées, les paiements échoués et les retraits de modération. Il répond toujours à quatre questions, avec les mots de l’application et des valeurs par défaut sensées : ce qui a été refusé, pourquoi (la règle, pour ce cas), qui décide (une personne avec ses initiales et un contact, une équipe, une politique ou un système automatisé) et ce que vous pouvez faire (des voies ordonnées, la première étant recommandée). Une voie de demande ou de recours passe par un événement annulable mv-request / mv-appeal avec waitUntil(promise), puis affiche son statut sur place (« Requested 2h ago from Maya Chen · Usually answered within a day ») avec un bouton Cancel request ; grant() lève le refus avec un bref anneau de succès, decline() affiche la réponse. « Pas encore » (un quota, une vérification, une date until décomptée qui lève le refus à l’heure dite) se distingue de « pas autorisé » par les mots et l’icône, jamais par la seule couleur ; le ton reste calme, un code de référence peut être affiché, et « Copy details » copie dans le presse-papiers un résumé prêt à transmettre au support.

CatégorieFeedback
TypeWeb Component (<mv-why-not>)
Statutbêta
KitDes états système honnêtes
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 More Thing instead

Installation

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

Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["why-not"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : 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.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<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>

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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 Northwind need an admin’s approval before they can see internal pages."
          decider="Workspace admins" decider-type="team" decider-role="IT · Northwind"
          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 Northwind 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 Visa 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>

Référence culturelle

Le Procès, Franz Kafka (1925, livre). Un homme est arrêté et poursuivi sans jamais apprendre de quoi on l’accuse, qui le juge ni comment faire appel, et chaque porte à laquelle il frappe se ferme sans explication. L’interface en est l’exact inverse : un refus ne vient jamais sans la règle qui le fonde, la personne ou le système qui décide, et des pistes concrètes pour avancer, dont des demandes et des recours en un clic dont l’avancement reste visible.

API

Attributes

NameTypeDefaultDescription
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

NameTypeDescription
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.

Cette page a été traduite par IA. Signaler un problème de traduction