익스클루시브

Wobble <mv-wobble>

삭제, 보관, 비활성화, 권한 회수, 게시 취소의 마찰을 해당 항목에 실제로 의존하는 것에 맞춰 조절하는 제거 영향 미리 보기입니다.

문화적 레퍼런스

젠가, 레슬리 스콧 (1983, 게임). 플레이어는 탑에서 블록을 빼기 전에 손가락으로 시험해 봅니다. 헐거운 블록은 쉽게 빠지고, 하중을 받는 블록은 탑을 흔들리게 하며, 게임을 하는 동안 모두가 어떤 조각이 전체를 떠받치는지 알게 됩니다. UI에서는 파괴적 작업에 호버하면 해당 항목에 의존하는 것들에 대해 항목을 시험하고, 삭제는 실행 취소와 함께 바로 진행되거나, 무엇이 깨지는지 나열하며 확인을 요청하거나, 하중이 재할당될 때까지 차단되며, 시험한 각 항목은 자신이 얼마나 떠받치고 있는지 계속 보여 줍니다.

작동 방식

삭제, 보관, 비활성화, 취소(revoke), 게시 취소의 마찰을 해당 항목에 실제로 의존하는 것에 맞춰 조절하는 삭제 영향 미리보기입니다. 어떤 목록, 표, 설정 페이지든 감싸면, 항목의 파괴적 작업에 호버하거나 포커스할 때 비동기 probe(id)(또는 data-wobble-report JSON 속성)로 항목을 “시험”하며, 호버 의도 감지, 진행 중 요청 중복 제거, 캐시 기간, 시간 초과를 지원하고, 고정된 카드가 삭제를 분류합니다. Loose: 아무것도 의존하지 않으면 제자리 실행 취소 바(Ctrl/Cmd+Z, 호버나 포커스 시 실행 취소 가능 시간 일시 정지)와 함께 즉시 삭제됩니다. Load-bearing: 무언가가 깨지거나 바뀌면 종류별로 개수와 예시와 함께 나열하고(“3 automations stop running · 12 invoices lose their region · 1 dashboard filter breaks”), 항목이 살짝 흔들리며, 실행하면 모든 결과를 나열하는 포커스된 alertdialog가 열리고, 임계값을 넘으면 선택적으로 확인 체크박스를 표시합니다. Foundational: 삭제가 차단되며, 이유, 다음 단계 링크, 그리고 “Reassign to…” 선택지를 제공합니다. 취소 가능한 mv-reassign(waitUntil 포함)은 항목을 다시 시험하기 전에 하중을 옮깁니다. 시험한 항목은 자신의 단계를 기억하며(data-wobble-tier, 선택적 [data-wobble-mark] 칩, 트리거의 접근 가능한 설명), survey는 보이는 모든 항목을 미리 시험해 어떤 조각이 전체를 떠받치는지 알 수 있게 합니다. mv-remove는 waitUntil과 함께 취소할 수 있고, mv-commit은 삭제가 확정되었음을 알립니다.

카테고리피드백
유형Web Component (<mv-wobble>)
상태안정
키트안전한 파괴적 작업과 비밀 정보
함께 설치되는 항목button, checkbox, select
Keywordsexclusive, culture, delete, remove, archive, revoke, impact, dependencies, blast-radius, confirm, undo, reassign, destructive-action, safe-delete, admin, settings, alertdialog

When to use

  • Deleting a tag, custom field, role or category may silently break automations, reports or records that use it
  • Admins revoke API keys, remove team members or disable feature flags and must see what stops working before they do
  • Most deletions in a list are harmless and should stay one click, but a few need a real confirmation or are blocked
  • An entity cannot be removed while others depend on it, and people should be offered to reassign them in place

Avoid when

  • The change is a value edit whose effects on other values in the page should be previewed, not a removal → use Butterfly instead
  • Every removal needs the same confirmation regardless of what depends on it, and there is no dependency data → use Dialog instead
  • The action is critical and must be armed by two people or two deliberate keys, not just informed by its impact → use Two-Key instead

설치

node scripts/add.mjs wobble --out ./src/marvelous

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["wobble"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/checkbox/checkbox.css, components/select/select.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/position.js, components/wobble/wobble.js, components/wobble/wobble.css.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

<mv-wobble verb="delete">
  <p data-wobble-id="vip" data-wobble-name="VIP" data-wobble-report='{"dependents":[{"count":3,"noun":"automation|automations","effect":"stop running","impact":"breaks"}]}'>VIP <button data-wobble-remove>Delete</button></p>
  <p data-wobble-id="old" data-wobble-name="Old leads" data-wobble-report='{"dependents":[]}'>Old leads <button data-wobble-remove>Delete</button></p>
</mv-wobble>

기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:

<div id="wb-demo" style="width:min(100%,46rem);margin-inline:auto">
  <style>
    #wb-demo { display:grid; gap:1.25rem; align-content:start }
    #wb-demo .wb-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #wb-demo .wb-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.125rem .875rem; border-bottom:1px solid var(--mv-border) }
    #wb-demo .wb-head h4 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #wb-demo .wb-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #wb-demo .wb-list { margin:0; padding:.375rem; list-style:none; display:grid; gap:.125rem }
    #wb-demo .wb-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto auto; align-items:center; gap:.75rem; padding:.5rem .5rem .5rem .75rem; border-radius:var(--mv-radius-md) }
    #wb-demo .wb-row:hover { background-color:var(--mv-bg-subtle) }
    #wb-demo .wb-dot { width:.625rem; height:.625rem; border-radius:50%; background:var(--c) }
    #wb-demo .wb-key { display:grid; place-items:center; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
    #wb-demo .wb-key svg { width:1rem; height:1rem }
    #wb-demo .wb-name { display:grid; gap:.0625rem; min-width:0 }
    #wb-demo .wb-name strong { font-size:.875rem; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #wb-demo .wb-name span { color:var(--mv-fg-muted); font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #wb-demo .wb-name code { font:.75rem var(--mv-font-mono) }
    #wb-demo .wb-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #wb-demo .wb-controls { display:flex; align-items:center; gap:.5rem 1.25rem; flex-wrap:wrap }
    #wb-demo .wb-controls .mv-choice { font-size:.8125rem }
    #wb-demo .wb-log { margin:0; min-height:1.1rem; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono); overflow-wrap:anywhere }
    #wb-demo mv-wobble { padding-bottom:.25rem }
    @media (max-width:36rem) {
      #wb-demo .wb-row { grid-template-columns:auto minmax(0,1fr) auto; }
      #wb-demo .wb-row [data-wobble-mark] { grid-column:2; grid-row:2; justify-self:start }
    }
  </style>

  <!-- Main: workspace labels, probed through an async callback; survey tests every visible label up front -->
  <section class="wb-card" aria-labelledby="wb-labels-title">
    <div class="wb-head">
      <div>
        <h4 id="wb-labels-title">Labels</h4>
        <p>Used on tickets, invoices, automations and reports. Hover or focus “Delete” to see what depends on a label.</p>
      </div>
      <span class="mv-badge" data-variant="secondary">6 labels</span>
    </div>

    <mv-wobble id="wb-labels" verb="delete" survey>
      <ul class="wb-list">
        <li class="wb-row" data-wobble-id="emea" data-wobble-name="Region: EMEA">
          <span class="wb-dot" style="--c:oklch(0.62 0.15 250)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Region: EMEA</strong><span>Europe, Middle East and Africa accounts</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Region: EMEA">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="legacy" data-wobble-name="Legacy import 2023">
          <span class="wb-dot" style="--c:oklch(0.7 0.02 260)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Legacy import 2023</strong><span>Created by the CSV migration, Mar 14, 2023</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Legacy import 2023">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="billing" data-wobble-name="Billing contact">
          <span class="wb-dot" style="--c:oklch(0.62 0.17 25)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Billing contact</strong><span>Marks who receives invoices on each account</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Billing contact">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="vip" data-wobble-name="VIP">
          <span class="wb-dot" style="--c:oklch(0.72 0.15 80)" aria-hidden="true"></span>
          <span class="wb-name"><strong>VIP</strong><span>Accounts above $50,000 ARR</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete VIP">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="webinar" data-wobble-name="Q2 webinar leads">
          <span class="wb-dot" style="--c:oklch(0.66 0.14 160)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Q2 webinar leads</strong><span>Imported from the June 12 registration list</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Q2 webinar leads">Delete</button>
        </li>
        <li class="wb-row" data-wobble-id="churn" data-wobble-name="Churn risk">
          <span class="wb-dot" style="--c:oklch(0.6 0.16 320)" aria-hidden="true"></span>
          <span class="wb-name"><strong>Churn risk</strong><span>Set by the health score below 40</span></span>
          <span data-wobble-mark></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Churn risk">Delete</button>
        </li>
      </ul>
    </mv-wobble>

    <div class="wb-foot">
      <span class="wb-controls">
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wb-ack"> Require acknowledgment above 10 affected</label>
        <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wb-slow"> Slow server</label>
      </span>
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="wb-reset">Reset demo</button>
      <p class="wb-log" id="wb-log" aria-live="polite">Testing every label…</p>
    </div>
  </section>

  <!-- Markup only: API keys, reports in data-wobble-report, verb "revoke"; marks appear as keys get tested -->
  <section class="wb-card" aria-labelledby="wb-keys-title">
    <div class="wb-head">
      <div>
        <h4 id="wb-keys-title">API keys</h4>
        <p>Reports come from the markup, no script. Each key remembers its tier once tested.</p>
      </div>
    </div>
    <mv-wobble id="wb-keys" verb="revoke">
      <ul class="wb-list">
        <li class="wb-row" data-wobble-id="key-staging" data-wobble-name="Staging CI"
            data-wobble-report='{"dependents":[]}'>
          <span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
          <span class="wb-name"><strong>Staging CI</strong><span><code>key_test_…a91f</code> · last used 94 days ago</span></span>
          <span data-wobble-mark="icon"></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Staging CI">Revoke</button>
        </li>
        <li class="wb-row" data-wobble-id="key-flowline" data-wobble-name="Flowline integration"
            data-wobble-report='{"dependents":[{"count":4,"noun":"workflow|workflows","effect":"stop running","impact":"breaks","items":["New invoice → team chat","Failed payment → task board","Weekly MRR sheet","Refund → help desk"]},{"count":1,"noun":"scheduled export|scheduled exports","effect":"fails on Monday","impact":"breaks","items":["Finance export (Mon 6:00 AM)"]}]}'>
          <span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
          <span class="wb-name"><strong>Flowline integration</strong><span><code>key_live_…3c7d</code> · used 2 minutes ago</span></span>
          <span data-wobble-mark="icon"></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Flowline integration">Revoke</button>
        </li>
        <li class="wb-row" data-wobble-id="key-webhook" data-wobble-name="Production webhook signer"
            data-wobble-report='{"reason":"Live webhooks sign every event with this key. Move them to another signing key first.","dependents":[{"count":2,"noun":"live webhook|live webhooks","effect":"can’t verify events","impact":"blocks","items":["orders.created → fulfillment","payments.failed → dunning"]}],"next":[{"label":"Create a new signing key","href":"#keys-new"},{"label":"Open webhook settings","href":"#webhooks"}]}'>
          <span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
          <span class="wb-name"><strong>Production webhook signer</strong><span><code>sig_…e20b</code> · signs 1,284 events a day</span></span>
          <span data-wobble-mark="icon"></span>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Production webhook signer">Revoke</button>
        </li>
      </ul>
    </mv-wobble>
  </section>

  <script type="module">
    const labels = document.getElementById("wb-labels");
    const log = document.getElementById("wb-log");
    const slow = document.getElementById("wb-slow");
    const say = (text) => { log.textContent = text; };

    // What the server knows about each label (a real app would query its dependency graph).
    const initial = () => ({
      emea: { dependents: [
        { kind: "automation", count: 3, noun: "automation|automations", effect: "stop running", impact: "breaks", items: ["EMEA ticket routing", "VAT reminder", "Weekly EMEA digest"], href: "#automations" },
        { kind: "invoice", count: 12, noun: "invoice|invoices", effect: "lose their region", impact: "changes", items: ["INV-2041 · Tidewell", "INV-2038 · Brightloom"] },
        { kind: "filter", count: 1, noun: "dashboard filter|dashboard filters", effect: "breaks", impact: "breaks", items: ["Revenue by region"], href: "#dashboards" },
      ] },
      legacy: { dependents: [] },
      billing: {
        reason: "Every active contract needs a label that marks who receives its invoices.",
        dependents: [
          { kind: "contract", count: 3, noun: "active contract|active contracts", effect: "require it", impact: "blocks", items: ["Tidewell Traders", "Kitebridge Logistics", "Harbor Labs"] },
          { kind: "contact", count: 11, noun: "contact|contacts", effect: "carry it", impact: "changes" },
        ],
        reassign: { label: "Reassign its contracts and contacts to", targets: [{ value: "finance", label: "Finance contact" }, { value: "primary", label: "Primary contact" }] },
      },
      vip: { dependents: [
        { kind: "contact", count: 48, noun: "account|accounts", effect: "lose the VIP label", impact: "changes", items: ["Saltmarsh Foods", "Ridgeway Clinics", "Orchard Credit Union"] },
        { kind: "view", count: 2, noun: "saved view|saved views", effect: "come back empty", impact: "breaks", items: ["VIP renewals", "High-touch accounts"] },
      ] },
      webinar: { dependents: [] },
      churn: { dependents: [
        { kind: "automation", count: 1, noun: "automation|automations", effect: "stops running", impact: "breaks", items: ["Save-desk alert"] },
        { kind: "account", count: 7, noun: "account|accounts", effect: "lose the label", impact: "changes" },
      ] },
    });
    let graph = initial();
    const wait = (ms) => new Promise((r) => setTimeout(r, ms));

    labels.probe = async (id) => {
      await wait(slow.checked ? 1600 : 280 + (id.length % 4) * 90);
      return structuredClone(graph[id]);
    };

    let tested = 0;
    labels.addEventListener("mv-probe", (e) => {
      tested++;
      if (tested === 6) say("All 6 labels tested: 2 loose, 3 load-bearing, 1 foundational.");
    });
    labels.addEventListener("mv-remove", (e) => {
      const { id, tier } = e.detail;
      say(`mv-remove · ${id} (${tier})`);
      // A real app would call its API here: e.detail.waitUntil(fetch(…, { method: "DELETE" }))
      if (tier !== "loose") e.detail.waitUntil(wait(slow.checked ? 1200 : 450));
    });
    labels.addEventListener("mv-undo", (e) => say(`mv-undo · ${e.detail.id} restored`));
    labels.addEventListener("mv-commit", (e) => say(`mv-commit · ${e.detail.id} deleted for good`));
    labels.addEventListener("mv-reassign", (e) => {
      const { id, to, toLabel } = e.detail;
      say(`mv-reassign · ${id} → ${to}`);
      // Once the contacts are moved and the contracts point to “${toLabel}”, nothing depends on the label anymore.
      e.detail.waitUntil(wait(slow.checked ? 1400 : 600).then(() => { graph[id] = { dependents: [] }; }));
    });

    document.getElementById("wb-ack").addEventListener("change", (e) => {
      if (e.target.checked) labels.setAttribute("acknowledge-at", "10");
      else labels.removeAttribute("acknowledge-at");
    });
    document.getElementById("wb-reset").addEventListener("click", () => {
      labels.close();
      graph = initial();
      tested = 0;
      labels.invalidate();
      for (const item of labels.querySelectorAll("[data-wobble-id]")) {
        item.hidden = false;
        item.removeAttribute("data-wobble-removed");
        item.removeAttribute("data-wobble-tier");
        item.querySelector("[data-wobble-mark]").replaceChildren();
      }
      labels.survey = false;
      labels.survey = true;
      say("Testing every label…");
    });

    const keys = document.getElementById("wb-keys");
    keys.addEventListener("mv-commit", (e) => {
      // Markup demo: keys come back after a while so the example stays usable.
      setTimeout(() => { e.detail.item.hidden = false; e.detail.item.removeAttribute("data-wobble-removed"); }, 2500);
    });
  </script>
</div>

API

Attributes

Name유형DefaultDescription
triggerCSS selector[data-wobble-remove]Destructive actions inside the element that test and remove an item. Disabled ones (:disabled, aria-disabled="true", inside [inert]) are ignored. Their default action (link, submit) is prevented: the component decides.
verbstring ("delete" | "archive" | "revoke|revoked"…)removeVerb used in every text (“Delete “VIP”?”, “Revoke anyway”, “Staging CI revoked”). The past tense is derived (adds d/ed) or given after a pipe. Per item: data-wobble-verb on the item or the trigger.
cachetime ("30s", "2m", "0")30sHow long a probe result is reused for hovers and clicks. It bounds how stale the preview can be; your server must still refuse removals that became unsafe.
undotime5sUndo window of a loose removal before mv-commit. 0 commits at once. A new removal commits the pending one.
removalhide | remove | nonehideWhat happens to the item once removed: hide collapses it and sets hidden (restored by undo), remove also takes it out of the DOM at commit, none leaves the DOM to your app (frameworks that own the list).
acknowledge-atnumberWhen a load-bearing or unverified removal affects at least this many things, the confirm requires checking “I understand N items will be affected” before the destructive button works.
probe-delaynumber (ms)150Hover intent before a pointer hover probes an item (fly-overs don’t call your API). Focus probes immediately; moving from one trigger to the next while the card is open switches after 60 ms.
placementtop | bottom | left | right (-start | -end)bottom-endPreferred side of the card relative to the trigger; flips and shifts to stay in the viewport.
surveybooleanTests every trigger as it scrolls into view (IntersectionObserver), so marks and descriptions show the whole structure before anyone hovers. Calls probe once per item: batch on your side if needed.
data-wobble-id / data-wobble-namestringOn the item (any ancestor of the trigger): its id passed to probe and events, and the name used in texts. Without an item, data-wobble-remove="id" on the trigger itself works too.
data-wobble-reportJSON reportOn the item: a report written in the markup (same shape as probe’s result). Takes precedence over probe; changing it invalidates the cache.
data-wobble-mark"" | "icon"An empty element inside the item that the component fills with a tier chip once the item is tested (icon, name and count; icon only with "icon"). It is aria-hidden: the tier reaches assistive tech through the trigger’s description.
data-wobble-tier / data-wobble-active / data-wobble-removedset by the componentOn items: the last known tier (loose | load-bearing | foundational | unknown), the item currently being tested (tinted), and a removed item waiting for its undo window.

Properties

Name유형Description
probe(id, { item, trigger, signal }) => Report | Promise<Report>Returns what depends on the item: { dependents: [{ count, noun: "invoice|invoices", effect: "lose their region", impact: "breaks" | "changes" | "blocks", text?, items?: string[], href?, kind? }], tier?, blocked?, reason?, reassign?: { label?, targets: [{ value, label }] }, next?: [{ label, href }] }. The tier is derived when omitted: blocked or any "blocks" impact → foundational, any count → load-bearing, none → loose. A rejection, a timeout (10 s) or an unreadable result gives the unverified tier, which always asks for confirmation. Setting it clears the cache.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (tier names, titles, hints, buttons, undo, errors), with {name}, {Name}, {verb}, {Verb}, {past}, {total}, {things} placeholders. English defaults.
trigger / verb / cache / undo / removal / acknowledgeAt / probeDelay / placement / surveyreflectedMirror the attributes.

Methods

NameDescription
check(target, { fresh? })Tests an item (id, item or trigger) and resolves to its normalized report ({ tier, dependents, total, reason, reassign, next, raw }), or null if not found. fresh bypasses the cache.
invalidate(id?)Forgets the cached report of one item, or all of them (after a change made elsewhere).
preview(target)Opens the impact card for an item without moving focus (onboarding, a “What uses this?” link).
requestRemoval(target)Same as activating the item’s trigger: removes, confirms or blocks according to its tier.
undoRemoval()Undoes the pending loose removal. Returns true if an item was restored.
close()Closes the card (a pending confirm is cancelled).

Events

NameDescription
mv-probeA fresh report arrived (not fired for cache hits). detail: { id, item, trigger, tier, report }.
mv-removeCancelable, when the removal is decided: at once for loose items, after the confirm otherwise. detail: { id, item, trigger, tier, report, waitUntil(promise) }. preventDefault() vetoes it; waitUntil shows a busy state and a rejection keeps the item with an error. For loose items, prefer committing on mv-commit (undo stays possible until then).
mv-reassignCancelable, “Reassign” was chosen on a foundational item. detail: { id, item, trigger, to, toLabel, report, waitUntil(promise) }. A promise resolving to a report uses it as the new state; otherwise the item is probed again and the card switches to its new tier.
mv-undoA loose removal was undone within its window. detail: { id, item, trigger, report }.
mv-commitThe removal is final: right after mv-remove for confirmed removals, at the end of the undo window (or on pagehide, or when the element is removed) for loose ones. detail: { id, item, tier, report }.
mv-errorA probe, removal or reassignment failed. detail: { action: "probe" | "remove" | "reassign", id, error }.

Content structure

NameDescription
(content)Any list, table or layout of items with their destructive actions. The component appends its card (Popover API top layer), undo bar, hidden descriptions and live regions after your content, and only touches your DOM through data-wobble-* attributes, aria-describedby on triggers, [data-wobble-mark] contents and the hidden state of removed items (unless removal="none").

CSS classes

NameDescription
mv-wobble-cardThe impact card. data-mode="preview | confirm | blocked", data-tier="checking | loose | load-bearing | foundational | unknown". Parts: -head, -tier (-tier-icon, -tier-label), -scale (three rising bars filled up to the tier), -title, -text, -list, -dep (data-impact, -dep-icon, -dep-text, -dep-items, -dep-link), -next, -reassign, -ack, -error, -hint, -actions (-cancel, -confirm).
mv-wobble-undoSticky undo bar after a loose removal: -undo-icon, -undo-text, -undo-btn, -undo-meter (depleting line, data-paused while held).
mv-wobble-mark-icon / -mark-label / -mark-countParts written inside your [data-wobble-mark] elements (data-tier set on the mark).

CSS variables

NameDefaultDescription
--mv-wobble-loosevar(--mv-success)Tone of loose items (card, marks, undo bar).
--mv-wobble-load-bearingvar(--mv-warning)Tone of load-bearing items.
--mv-wobble-foundationalvar(--mv-danger)Tone of foundational items and of consequences that break or block.
--mv-wobble-amount1Strength of the item’s motion when tested (0 turns it off, up to 3): the wobble of load-bearing items and the small slide of loose ones. Foundational items never move.

Accessibility

Triggers stay your own buttons or links with their names; the component adds to each tested trigger an aria-describedby pointing to a hidden text that states the tier and every consequence (“Load-bearing. 16 items depend on “Region: EMEA”. 3 automations stop running; 12 invoices lose their region; 1 dashboard filter breaks.”). When a trigger receives keyboard focus, the result is also announced politely once the probe returns, so nobody has to hover. The preview card is a visual mirror (aria-hidden, nothing focusable) that never takes focus; it stays open while the pointer rests on it and Escape dismisses it (WCAG 1.4.13). Activating a load-bearing, unverified or foundational item opens the card as a labelled, described role="alertdialog" (aria-modal) with focus trapped inside: focus lands on Cancel (the least destructive action), or on the reassignment select when the removal is blocked, and blocked removals are announced assertively. Consequences are a real list; tiers are always stated in words next to an icon (a sliding block, a block under load, a lock), never by color alone, and the rising-bars scale is decorative. Escape or Cancel closes the confirm and returns focus to the trigger; an outside press cancels it. The acknowledgment is a native checkbox with a label; the destructive button is aria-disabled (still focusable) until it is checked and explains why if pressed. Pending actions set aria-busy on the button and the trigger; failures are announced assertively and shown in the card. After a removal, focus moves to the Undo button (loose) or to the next item’s trigger, never to the body; undo returns focus to the restored trigger, Ctrl/Cmd+Z works while the undo bar is shown, and the undo window pauses while the bar is hovered or focused. Reduced motion (OS or data-motion="reduce"): no wobble, no slide, no collapse or spinner animation; the tier text carries everything. Forced colors: marks, tier pill and scale get system-color borders and fills, the tested item gets a Highlight outline. Known limits: the tier is only as accurate as the report the app returns (the component never guesses dependents); a probe that takes longer than 10 seconds fails with the timeout message; one pending undo at a time (a second loose removal commits the first); a pending removal is committed on pagehide, so for loose items the app should perform the real delete on mv-commit rather than on mv-remove.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고