익스클루시브

Unobserved <mv-unobserved>

누군가 보고 있는 동안에는 절대 순서를 바꾸거나 움직이지 않는 라이브 영역(피드, 리더보드, 호가창, 알림 목록, 서버와 동기화되는 칸반)입니다.

문화적 레퍼런스

닥터 후, “블링크”, 스티븐 모팻 (BBC) (2007, 시리즈). 이 에피소드의 석상 괴물은 아무도 보고 있지 않을 때만 움직일 수 있고, 누군가 보는 순간 얼어붙습니다. UI에서는 실시간 목록이 관찰되지 않을 때만 순서를 바꿉니다. 포인터, 포커스, 선택 또는 최근 활동이 머무는 동안에는 모든 변경이 멈춰 있다가, 주의가 떠나는 순간 모든 것이 한꺼번에 움직입니다.

작동 방식

누군가 보고 있는 동안에는 절대 순서를 바꾸거나 움직이지 않는 실시간 영역(피드, 리더보드, 호가창, 알림 목록, 서버와 동기화되는 칸반)입니다. 보고 있다는 것은 포인터가 안에 있거나, 안에서 누르기가 시작되었거나(드래그 포함), 포커스나 텍스트 선택이 안에 있거나, 최근 몇 초 사이에 안에서 스크롤, 휠, 키 또는 터치 활동이 있었고, 영역이 화면에 보이며 탭이 표시된 상태임을 뜻합니다. attention="visible"을 사용하면 화면에 보이는 것만으로 충분합니다. 그사이 도착하는 변경(queue(fn), 키 기반 렌더링을 쓰는 setItems(), 또는 watch가 켜져 있을 때 목록의 일반 DOM 변경을 통해 들어오며, 프레임워크가 렌더링한 목록은 역변환으로 제자리에 고정되고, 새 행은 흐름 밖에서 대기하며, 삭제된 행은 비활성 고스트로 남습니다)은 레이아웃 공간을 차지하지 않는 은은한 “3 updates waiting · Show now” 칩 뒤에 보류됩니다. 주의가 떠날 때(유예 시간 후), 칩을 사용할 때, 또는 아무것도 영원히 보류되지 않도록 max-hold 이후 부드럽게, 모든 변경이 FLIP으로 한꺼번에 반영되어 각 이동을 추적할 수 있으며, 이동되거나 새로 생기거나 변경되거나 긴급한 행에는 잠깐 윤곽선이 표시됩니다. 긴급한 변경은 보류를 깨고, key는 같은 행의 반복 업데이트를 합치며, 포커스와 스크롤 위치는 유지되고, 대기 중인 개수와 적용된 각 묶음은 정중하게(polite) 안내됩니다.

카테고리데이터 표시
유형Web Component (<mv-unobserved>)
상태안정
키트알림 피로 없는 모니터링
Keywordsexclusive, culture, live-data, realtime, feed, leaderboard, order-book, kanban, notifications, reorder, layout-shift, stable-layout, buffer, pending-updates, new-items-pill, attention, flip, focus-preservation, scroll-anchoring, mutation-observer

When to use

  • A leaderboard, order book or ranked list re-sorts from live data and users need to click rows without them jumping away
  • A notification or activity feed receives new items while someone reads it, and inserts must not push the text they are reading
  • A kanban board or queue synced from the server must not move a card while it is hovered, focused or dragged
  • A framework re-renders a list on every server message and the layout must stay still until the user looks away

Avoid when

  • Changes should land instantly and simply be pointed out once they have happened → use Deja Vu instead
  • The user needs a summary of what changed while the tab was hidden or they were away → use While Away instead
  • The values are a ticker updated many times per second where every tick matters: holding would show stale prices

설치

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

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

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/unobserved/unobserved.js, components/unobserved/unobserved.css.

사용법

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

<mv-unobserved watch>
  <ol data-unobserved-list>
    <li>Maya Chen · $48,200</li>
    <li>Diego Alvarez · $41,900</li>
    <li>Priya Natarajan · $39,400</li>
  </ol>
</mv-unobserved>

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

<div id="uo-demo" style="width:min(100%,64rem);margin-inline:auto">
  <style>
    #uo-demo { --uo-row: 3.25rem }
    #uo-demo .uo-layout { display:grid; grid-template-columns:minmax(0,1fr) 19rem; gap:1.25rem; align-items:start }
    #uo-demo .uo-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
    #uo-demo .uo-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:1rem 1.125rem .75rem }
    #uo-demo .uo-head h3, #uo-demo .uo-head h4 { margin:0; font-size:1rem; font-weight:650; letter-spacing:-.01em }
    #uo-demo .uo-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
    #uo-demo .uo-pulse { display:inline-block; width:.4rem; height:.4rem; margin-inline-end:.35rem; border-radius:50%; background:currentColor; vertical-align:.08em }
    #uo-demo .uo-cols, #uo-demo .uo-row { display:grid; grid-template-columns:2.25rem minmax(0,1fr) 3.5rem 7.25rem 5.5rem; align-items:center; gap:.75rem; padding:0 1.125rem }
    #uo-demo .uo-cols { padding-block:.5rem; border-block:1px solid var(--mv-border); background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
    #uo-demo .uo-cols span:nth-child(3), #uo-demo .uo-cols span:nth-child(4) { text-align:end }
    #uo-demo .uo-list { list-style:none; margin:0; padding:.25rem 0 .375rem }
    #uo-demo .uo-row { min-height:var(--uo-row); border-radius:var(--mv-radius-lg); margin-inline:.375rem; padding-inline:.75rem; background:var(--mv-surface) }
    #uo-demo .uo-row:hover { background:var(--mv-bg-subtle) }
    #uo-demo .uo-rank { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:50%; color:var(--mv-fg-muted); font-size:.8125rem; font-weight:600; font-variant-numeric:tabular-nums }
    #uo-demo .uo-row[data-top] .uo-rank { background:var(--mv-accent-subtle); color:var(--mv-accent) }
    #uo-demo .uo-rep { display:flex; align-items:center; gap:.625rem; min-width:0 }
    #uo-demo .uo-av { flex:none; display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:oklch(0.56 0.15 var(--h)); color:var(--mv-fg-on-accent); font-size:.6875rem; font-weight:650; letter-spacing:.02em }
    #uo-demo .uo-who { display:grid; min-width:0 }
    #uo-demo .uo-who b { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.875rem; font-weight:600 }
    #uo-demo .uo-who small { color:var(--mv-fg-muted); font-size:.75rem }
    #uo-demo .uo-deals { text-align:end; color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
    #uo-demo .uo-rev { display:grid; justify-items:end; font-variant-numeric:tabular-nums }
    #uo-demo .uo-rev b { font-size:.875rem; font-weight:650 }
    #uo-demo .uo-rev small { min-height:1em; color:var(--mv-success); font-size:.6875rem; font-weight:600 }
    #uo-demo .uo-rev small[data-down] { color:var(--mv-danger) }
    #uo-demo .uo-row .mv-button { justify-self:end }
    #uo-demo .uo-row[data-unobserved-moved="up"] .uo-rank { color:var(--mv-success) }

    #uo-demo .uo-side { display:grid; gap:1rem }
    #uo-demo .uo-hud { padding:1rem 1.125rem; display:grid; gap:.875rem; font-size:.8125rem }
    #uo-demo .uo-hud h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; font-size:.875rem; font-weight:650 }
    #uo-demo .uo-signals { list-style:none; margin:0; padding:0; display:grid; grid-template-columns:1fr 1fr; gap:.375rem }
    #uo-demo .uo-signals li { display:flex; align-items:center; gap:.5rem; padding:.375rem .5rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font-size:.75rem; transition:background-color var(--mv-duration-fast), color var(--mv-duration-fast) }
    #uo-demo .uo-signals li::before { content:""; flex:none; width:.5rem; height:.5rem; border-radius:50%; background:var(--mv-border-strong); transition:background-color var(--mv-duration-fast), box-shadow var(--mv-duration-fast) }
    #uo-demo .uo-signals li[data-on] { background:var(--mv-accent-subtle); color:var(--mv-fg) }
    #uo-demo .uo-signals li[data-on]::before { background:var(--mv-accent); box-shadow:0 0 0 3px color-mix(in oklab, var(--mv-accent) 22%, transparent) }
    #uo-demo .uo-status { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45; min-height:2.9em }
    #uo-demo .uo-status b { color:var(--mv-fg); font-weight:600 }
    #uo-demo .uo-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #uo-demo .uo-sep { height:1px; background:var(--mv-border) }

    #uo-demo .uo-feed .uo-head { padding-bottom:.5rem }
    #uo-demo #uo-feed { --mv-unobserved-offset:1.125rem; --mv-unobserved-inset:1rem }
    #uo-demo #uo-board { --mv-unobserved-offset:1px }
    #uo-demo .uo-events { list-style:none; margin:0; padding:.5rem .5rem .625rem; display:grid; gap:.125rem; min-height:16.5rem; align-content:start }
    #uo-demo .uo-ev { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.125rem .625rem; align-items:start; padding:.5rem .625rem; border-radius:var(--mv-radius-lg); background:var(--mv-surface); font-size:.75rem; line-height:1.4 }
    #uo-demo .uo-ev .uo-av { grid-row:span 2; width:1.625rem; height:1.625rem; font-size:.625rem }
    #uo-demo .uo-ev p { margin:0; color:var(--mv-fg) }
    #uo-demo .uo-ev p b { font-weight:600 }
    #uo-demo .uo-ev time { color:var(--mv-fg-subtle); font-size:.6875rem }
    #uo-demo .uo-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
    @media (max-width:54rem) {
      #uo-demo .uo-layout { grid-template-columns:minmax(0,1fr) }
    }
    @media (max-width:36rem) {
      #uo-demo .uo-cols, #uo-demo .uo-row { grid-template-columns:2rem minmax(0,1fr) 6rem; gap:.5rem }
      #uo-demo .uo-cols span:nth-child(3), #uo-demo .uo-deals, #uo-demo .uo-cols span:nth-child(5), #uo-demo .uo-row .mv-button { display:none }
    }
  </style>

  <div class="uo-layout">
    <section class="uo-card" aria-labelledby="uo-board-title">
      <div class="uo-head">
        <div>
          <h3 id="uo-board-title">Sales leaderboard</h3>
          <p>Q3 closed-won revenue · synced from the CRM</p>
        </div>
        <span class="mv-badge" data-variant="success" data-shape="pill" id="uo-live"><span class="uo-pulse" aria-hidden="true"></span>Live</span>
      </div>
      <mv-unobserved id="uo-board" grace="900ms" max-hold="25s">
        <div class="uo-cols" aria-hidden="true"><span>#</span><span>Rep</span><span>Deals</span><span>Revenue</span><span></span></div>
        <ol class="uo-list" data-unobserved-list aria-label="Sales reps ranked by revenue"></ol>
      </mv-unobserved>
    </section>

    <aside class="uo-side">
      <div class="uo-card uo-hud" aria-label="Attention monitor">
        <h4>Attention on the board <span class="mv-badge" data-variant="secondary" data-shape="pill" id="uo-state">Live</span></h4>
        <ul class="uo-signals" id="uo-signals">
          <li data-k="pointer">Pointer inside</li>
          <li data-k="press">Press</li>
          <li data-k="focus">Focus inside</li>
          <li data-k="selection">Text selected</li>
          <li data-k="activity">Scroll or keys</li>
          <li data-k="hold">App hold</li>
        </ul>
        <p class="uo-status" id="uo-status">Nobody is looking: updates land live, with a glide.</p>
        <div class="uo-sep"></div>
        <label class="mv-choice" data-control="end">
          <input type="checkbox" role="switch" class="mv-switch" id="uo-feed-on" checked>
          <span class="mv-choice-text"><span class="mv-choice-title">CRM sync</span></span>
        </label>
        <div class="uo-actions">
          <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="uo-urgent">Reverse a deal (urgent)</button>
          <button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="uo-apply">Apply now</button>
        </div>
      </div>

      <section class="uo-card uo-feed" aria-labelledby="uo-feed-title">
        <mv-unobserved id="uo-feed" attention="visible" max-hold="12s" chip-align="end">
          <div class="uo-head">
            <div>
              <h4 id="uo-feed-title">Team activity</h4>
              <p>Held while on screen</p>
            </div>
          </div>
          <ul class="uo-events" data-unobserved-list aria-label="Recent team activity"></ul>
        </mv-unobserved>
      </section>
    </aside>
  </div>
  <p class="uo-hint">Hover the board and try to click “Message” while the CRM pushes new deals: nothing moves until you leave · Tab into a row: focus holds it too</p>

  <script type="module">
    const board = document.getElementById("uo-board");
    const feed = document.getElementById("uo-feed");
    await customElements.whenDefined("mv-unobserved");
    const $ = (id) => document.getElementById(id);
    const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
    const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
    const initials = (name) => name.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();

    let reps = [
      { id: "pr", name: "Priya Raman", city: "Bengaluru", hue: 285, deals: 21, revenue: 184200, delta: 0 },
      { id: "lo", name: "Liam O'Connor", city: "Dublin", hue: 155, deals: 19, revenue: 176900, delta: 0 },
      { id: "sm", name: "Sofia Martínez", city: "Madrid", hue: 25, deals: 18, revenue: 171400, delta: 0 },
      { id: "kw", name: "Kenji Watanabe", city: "Tokyo", hue: 235, deals: 17, revenue: 168300, delta: 0 },
      { id: "ao", name: "Amara Okafor", city: "Lagos", hue: 60, deals: 16, revenue: 163800, delta: 0 },
      { id: "ls", name: "Lucas Schmidt", city: "Berlin", hue: 200, deals: 15, revenue: 159500, delta: 0 },
      { id: "cd", name: "Chloé Dubois", city: "Montréal", hue: 340, deals: 14, revenue: 154100, delta: 0 },
      { id: "mr", name: "Mateo Rossi", city: "Milan", hue: 110, deals: 13, revenue: 149700, delta: 0 },
    ];
    const ranked = () => [...reps].sort((a, b) => b.revenue - a.revenue);

    board.renderItem = (rep, row, i) => {
      if (!row) {
        row = el("li", "uo-row");
        const av = el("span", "uo-av", initials(rep.name));
        av.style.setProperty("--h", rep.hue);
        av.setAttribute("aria-hidden", "true");
        const who = el("span", "uo-who");
        who.append(el("b", "", rep.name), el("small", "", rep.city));
        const repCell = el("span", "uo-rep");
        repCell.append(av, who);
        const rev = el("span", "uo-rev");
        rev.append(el("b"), el("small"));
        const msg = el("button", "mv-button", "Message");
        Object.assign(msg.dataset, { variant: "ghost", size: "sm" });
        msg.type = "button";
        msg.setAttribute("aria-label", `Message ${rep.name}`);
        row.append(el("span", "uo-rank"), repCell, el("span", "uo-deals"), rev, msg);
      }
      row.toggleAttribute("data-top", i < 3);
      row.querySelector(".uo-rank").textContent = i + 1;
      row.querySelector(".uo-deals").textContent = rep.deals;
      row.querySelector(".uo-rev b").textContent = usd.format(rep.revenue);
      const d = row.querySelector(".uo-rev small");
      d.textContent = rep.delta ? `${rep.delta > 0 ? "+" : "−"}${usd.format(Math.abs(rep.delta))}` : "";
      d.toggleAttribute("data-down", rep.delta < 0);
      return row;
    };
    board.items = ranked();

    // The CRM pushes a closed deal every 1.4 s; the board gets a fresh ranking each time.
    const push = (rep, amount, opts) => {
      reps = reps.map((r) => r.id === rep.id ? { ...r, revenue: r.revenue + amount, deals: r.deals + Math.sign(amount), delta: amount } : r);
      board.setItems(ranked(), opts);
    };
    let tick = 0;
    const timer = setInterval(() => {
      if (!board.isConnected) { clearInterval(timer); return; }
      if (!$("uo-feed-on").checked) return;
      tick++;
      // Mostly the chasing pack, so ranks really change.
      const pool = ranked().slice(tick % 3 === 0 ? 0 : 2);
      const rep = pool[Math.floor(Math.random() * pool.length)];
      push(rep, 1000 * (4 + Math.floor(Math.random() * 11)));
      if (tick % 2) addEvent(rep);
    }, 1400);

    $("uo-urgent").addEventListener("click", () => {
      const top = ranked()[0];
      push(top, -24000, { urgent: true, label: `${top.name}'s $24,000 deal was reversed` });
    });
    $("uo-apply").addEventListener("click", () => board.apply());

    // Attention monitor.
    const signals = [...$("uo-signals").children];
    const state = $("uo-state");
    const status = $("uo-status");
    const paint = () => {
      const n = board.pending;
      if (board.holding) {
        state.textContent = `Holding ${n}`;
        state.dataset.variant = "warning";
        status.replaceChildren("Someone is looking: ", el("b", "", `${n} ${n === 1 ? "update" : "updates"} held`), ". They land when attention leaves, or after 25 s.");
      } else if (board.observed) {
        state.textContent = "Watching";
        state.dataset.variant = "info";
        status.replaceChildren("Someone is looking: the next update will be held.");
      } else {
        state.textContent = "Live";
        state.dataset.variant = "secondary";
        status.replaceChildren("Nobody is looking: updates land live, with a glide.");
      }
    };
    board.addEventListener("mv-attention", (e) => {
      for (const li of signals) li.toggleAttribute("data-on", e.detail.reasons.includes(li.dataset.k));
      paint();
    });
    board.addEventListener("mv-hold", paint);
    board.addEventListener("mv-applied", paint);

    // Activity feed: plain DOM changes through queue(), held while the feed is on screen.
    feed.strings = {
      waiting: "{count} new", waitingOne: "1 new", show: "Show", // short: the chip shares the heading row of a narrow card
      announceHold: "{count} new events waiting. Choose Show to see them.",
      announceHoldOne: "1 new event waiting. Choose Show to see it.",
    };
    const list = feed.querySelector("[data-unobserved-list]");
    const clients = ["Tidewell Retail", "Hanamura Foods", "Blue Harbor Logistics", "Aster Health", "Kestrel Energy", "Brightloom Studio", "Orchid Hotels", "Pinecrest Bank"];
    const eventRow = (rep, text, time) => {
      const li = el("li", "uo-ev");
      const av = el("span", "uo-av", initials(rep.name));
      av.style.setProperty("--h", rep.hue);
      av.setAttribute("aria-hidden", "true");
      const p = el("p");
      p.append(el("b", "", rep.name), ` ${text}`);
      li.append(av, p, el("time", "", time));
      return li;
    };
    const seed = [
      [reps[3], "closed Hanamura Foods · $12,000", "2 min ago"],
      [reps[0], "moved Aster Health to negotiation", "6 min ago"],
      [reps[4], "closed Kestrel Energy · $9,000", "11 min ago"],
      [reps[6], "booked a demo with Orchid Hotels", "18 min ago"],
    ];
    list.append(...seed.map(([r, t, when]) => eventRow(r, t, when)));
    let n = 0;
    function addEvent(rep) {
      const client = clients[n++ % clients.length];
      const row = eventRow(rep, `closed ${client}`, "Just now");
      feed.queue(() => {
        list.prepend(row);
        const rows = list.querySelectorAll(":scope > .uo-ev:not(.mv-unobserved-ghost)");
        for (let i = 5; i < rows.length; i++) rows[i].remove();
      }, { label: `${rep.name} closed ${client}` });
    }
    // Two events right away, so the held state is visible from the start.
    setTimeout(() => board.isConnected && addEvent(reps[2]), 300);
    setTimeout(() => board.isConnected && addEvent(reps[5]), 800);
    paint();
  </script>
</div>

API

Attributes

Name유형DefaultDescription
gracetime ("1s", "900ms", ms number)1sHow long attention must be gone before held changes land. Changes that arrive during this window are held too, so a pointer overshooting the edge does not trigger a reshuffle.
max-holdtime20sLongest a change is held while someone keeps looking (minimum 1s). Then everything lands in a slower, gentle FLIP with focus and scroll preserved; a thin line in the chip shows the time left. Deferred while a press or drag is in progress. Not applied to an explicit hold().
idletime4sHow long scroll, wheel, key or touch activity inside the region keeps counting as attention (touch screens have no hover, so this is what holds changes while someone reads on a phone).
attentioninteraction | visibleinteractioninteraction: pointer, press, focus, selection or recent activity inside. visible: being on screen with the tab visible is enough (a news feed that shows “12 new posts” instead of inserting them, for screen reader and eye-only reading); changes then land through the chip, max-hold, scrolling away or hiding the tab.
placementtop | bottomtopWhere the chip floats. It is sticky inside the region (stays visible while a tall region scrolls) and absolutely positioned, so it never moves content.
chip-alignstart | center | endcenterHorizontal position of the chip in the region, e.g. end to sit it in the empty corner of a card header.
watchbooleanAlso hold child additions, removals and reorders of the [data-unobserved-list] containers made by anyone (React, Vue, Svelte, htmx…). While held, kept rows are pinned to where they were seen with inverse translate, new rows get data-unobserved-held="added" (display: none), removed rows are replaced by inert ghost clones and the list height is locked. Text and attribute changes inside rows are not held. A width change of a frozen list forces the apply (reason "layout").
announcepolite | offpoliteScreen reader announcements: when changes start being held, the count every 6 s at most while it grows, and a summary of each applied batch (“3 updates applied: 1 new, 2 moved.”). Urgent changes use an assertive region.
data-unobserved-listmarker attributeOn the container(s) whose element children are the moving items (one per kanban column is fine; moves between lists are animated). Without any, the component's own children are the items; in items mode a list is created when missing.
data-unobserved-moved / -new / -changed / -urgentset by the componentOn items for ~1.8 s after they landed: moved is "up" or "down" (only rows that actually changed rank, not the ones pushed by an insert), new for inserted rows, changed for rows whose data or text changed, urgent for rows of an urgent batch. Style them freely.
data-observed / data-holdingset by the componentOn the host while someone is looking / while changes are held. data-unobserved-frozen on a list frozen in watch mode, data-unobserved-flying on rows mid-FLIP.

Properties

Name유형Description
itemsunknown[]Keyed rendering: assign the full, latest array (typically on every server message). The first assignment renders at once; later ones are diffed by key and held while observed. Reading returns the latest array, held or not.
renderItem(item, element | null, index) => ElementCreates a row (element is null) or updates the existing one for the same key in place and returns it. Called when the item object or its index changed. Default: an <li> (or a role="listitem" <div>) showing item.label, name or title.
itemKey(item, index) => string | numberIdentity of an item across updates. Default: item.id, then item.key, else the index. Server-rendered children with a matching data-key are adopted.
pendingnumberHeld changes (read-only): queued jobs, plus rows affected by held items, plus rows added, removed or reordered in watch mode.
observedbooleanWhether someone is looking right now (read-only).
reasonsstring[]Why it counts as observed (read-only): "pointer", "press", "focus", "selection", "activity", "visible", "hold".
holdingbooleanTrue while changes are held and the chip is shown (read-only).
changesArray<{ source, label?, key?, time?, count? }>Held changes, oldest first (read-only). source: "queue" (one per job), "items" (with count and labels) or "watch" (with count).
stringsPartial<Record<string, string>>Overrides for every text: waiting ({count}), waitingOne, show, announceHold, announceHoldOne, announceCount, announceApplied ({count}, {parts}), announceAppliedOne, announceUrgent ({label}), added / removed / moved / changed ({n}). English defaults.

Methods

NameDescription
queue(fn, { label?, key?, urgent? })Runs fn (any DOM change of the region: prepend a row, move a card to another column…) right away if nobody is looking, batched per frame and animated, else holds it. key coalesces: a newer job with the same key replaces the held one. urgent breaks the hold and applies everything pending, in order. Returns Promise<boolean>: true once applied, false if replaced, discarded or if fn threw.
setItems(items, { urgent?, label? })Same as assigning items, with an urgent flag (a correction that must be seen now) and a label used in announcements and events. Returns false when the new array changes nothing.
apply(fn?, { label? })Applies every held change now (then fn, if given) with the usual FLIP. Cannot be vetoed. Returns the same summary as mv-applied, or null when there was nothing to apply.
discard()Drops held queue jobs (their promises resolve false) and held items (the rendered ones stay). Watched DOM changes are already in the DOM and stay held. Returns how many were dropped.
hold()Holds changes regardless of attention, e.g. while a dialog about one row is open elsewhere or during a custom drag. Returns release(); several holds stack. max-hold does not override it.

Events

NameDescription
mv-attentionAttention changed. detail: { observed, reasons, cause }.
mv-holdA change was held. detail: { pending, first (true for the first change of a hold), label }.
mv-applyCancelable, before held or live changes land. detail: { reason: "live" | "leave" | "offscreen" | "hidden" | "max-hold", pending, labels }. preventDefault() keeps them held until the next chance (attention leaving again, max-hold restarted, Show now). "show-now", "urgent", "api", "layout" and "disconnect" cannot be vetoed and do not emit it.
mv-appliedChanges landed. detail: { reason, count, added, removed, moved, changed (element arrays; removed elements are detached), labels }. Also emitted with reason "live" for unheld mutations seen in watch mode.

Content structure

NameDescription
(content)Any markup. Items are the element children of each [data-unobserved-list] (or of the component). The component prepends the chip layer (first in tab order) and appends two visually hidden live regions; both are ignored as items.

CSS classes

NameDescription
mv-unobserved-chipThe “N updates waiting · Show now” button (data-open while shown, data-count). Parts: mv-unobserved-icon, -count, -sep, -action, -meter (time left before max-hold).
mv-unobserved-layerAbsolutely positioned, pointer-events: none layer that holds the sticky chip (data-placement).
mv-unobserved-ghostInert, aria-hidden clone standing in for a removed row: data-unobserved-ghost="held" (dimmed, dashed) while held in watch mode, "exit" while it fades out.
mv-unobserved-listThe list created in items mode when no [data-unobserved-list] exists (role="list").

CSS variables

NameDefaultDescription
--mv-unobserved-colorvar(--mv-accent)Chip icon, action text and meter.
--mv-unobserved-offsetvar(--mv-space-3)Distance of the chip from the region edge (and from the viewport edge while it sticks).
--mv-unobserved-moved-colorvar(--mv-accent)Outline of moved rows.
--mv-unobserved-new-colorvar(--mv-success)Outline of inserted rows.
--mv-unobserved-changed-coloraccent at 55%Outline of rows whose content changed in place.
--mv-unobserved-urgent-colorvar(--mv-danger)Outline of rows landed by an urgent change.
--mv-unobserved-mark-width2pxOutline width (drawn inside the row, so it is never clipped).
--mv-unobserved-mark-duration1.8sHow long the outline takes to fade.
--mv-unobserved-ghost-opacity0.45Opacity of a removed row held in place (watch mode).

Accessibility

The point of the component is that nothing moves under a user's pointer, keyboard focus or text selection: held changes are applied only once attention has left, so a click or a keystroke always reaches the row the user aimed at. Focus is never stolen: when changes land, the focused element keeps focus (re-focused with preventScroll if a move blurred it, including rows moved by a framework in watch mode); if its row was removed, focus goes to the row now at the same position, then to the list (tabindex="-1"). Scroll position is preserved: when the user has scrolled into the list, the first visible row (or the focused one) is kept at the same screen position across the apply, and the browser's own scroll anchoring is disabled for that moment so it never corrects twice. The chip is a real <button> (“3 updates waiting · Show now”, the dot is aria-hidden) that comes first in the region's tab order, so keyboard users reach it before the rows, and only while shown (visibility: hidden otherwise); activating it applies the changes and, since the chip disappears, moves focus to the list. A visually hidden polite live region says when changes start being held and how to see them, updates the count at most every 6 s, and summarizes each applied batch (“3 updates applied: 1 new, 2 moved.”); urgent changes use a separate assertive region; announce="off" silences both. Ghost rows are aria-hidden and inert, and held new rows are display: none, so assistive technologies read exactly what is on screen. Screen readers in browse mode move a virtual cursor that pages cannot detect: use attention="visible" or hold() for regions that are mostly read rather than clicked. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no FLIP, no fade, no chip meter or count pulse; changes land instantly and the outlines stay static for their duration so moves remain traceable. Forced colors: the chip uses system button colors and the outlines use Highlight. Performance: nothing runs while idle (no loop); a batch measures every row once before and once after the change (read, write, read) and only animates rows near the viewport; arrivals within one frame are batched. Known limits: attention is inferred from pointer, focus, selection and recent activity only; a reader looking at the region without touching anything is not detected (use attention="visible" or hold() for that), and a screen reader virtual cursor is invisible to the page as noted above.

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