エクスクルーシブ

Inside Count <mv-inside-count>

閉じたタブ、アコーディオン、ページが、開く前に中身を教えてくれます。

文化的リファレンス

『マインスイーパ』、Microsoft(ロバート・ドナー & カート・ジョンソン、それ以前の地雷探しゲームに基づく)(1990年、ゲーム)。盤面の各数字は、隣接する閉じたマスに隠れた地雷の数を示すので、プレイヤーはすべてを開けなくても、どこを開けるかを判断できます。UI では、閉じたタブ、セクション、ページのトリガーに、そこに隠れているエラー、未入力の必須フィールド、未保存の変更、新着アイテム、検索一致の数が表示され、ショートカットで注意が必要な次の領域を開けます。

仕組み

開く前に中身を教えてくれる、閉じたタブ、アコーディオン、ページ。コンテナ(タブ形式の設定フォーム、アコーディオン、ステッパー、折りたたみ可能なサイドバー、ページ分割されたテーブル)をラップすると、閉じたすべての領域のトリガーに小さなラベル付きの件数が付きます。件数はコンテンツ自体から読み取られ、手作業でのフラグ付けは不要です:エラー(aria-invalid、[data-issue]、フィールドが変更されたかフォームが送信された後のネイティブの制約バリデーション)、まだ空の必須フィールド、未保存の変更(value、checked、選択状態がデフォルトと異なるもの)、新着または未読のアイテム([data-new]、[data-unread])、クエリまたはバインドされた検索フィールドに対する検索一致(アクセントと大文字小文字を区別せず、CSS Custom Highlight API で開いたパネルでも閉じたパネルでもハイライト)、さらに data-inside="kind" や signals ルールによるカスタムの種類。複数の種類は優先順に 1 つのコンパクトなピルにまとめられ(「1 error · 1 to fill · 2」)、種類ごとにグリフが付きます。compact モードと corner モードは、ページ番号のような小さなトリガーにも収まります。組み込みのアダプターは <details>、[role=tab] + aria-controls、[aria-expanded] + aria-controls の開閉要素、子グループを持つツリーアイテムに対応し、それ以外(ページ、コンテナの外にスクロールした列)には data-inside-for を使います。件数は集計され(閉じたセクションは閉じたサブセクションの件数を合算し、別の場所にあるタブパネルも含みます)、input、change、invalid、reset、DOM の変更に応じてリアルタイムに更新されます。Alt+Shift+N または任意の [data-inside-next] ボタンで、エラーのある次の閉じた領域を開き(途中の入れ子の領域も開きます)、最初の問題にフォーカスします。reveal-on-submit は非表示のタブ内でのネイティブバリデーションを修正します:最初の無効なフィールドを含むセクションが開き、ブラウザがそこにフォーカスできるようにします。markSaved() は現在の値をクリーンな状態として確定します。

カテゴリーナビゲーション
タイプWeb Component(<mv-inside-count>)
ステータス安定版
キットユーザーを取りこぼさないフォーム
Keywordsexclusive, culture, tabs, accordion, details, stepper, pagination, sidebar, disclosure, validation, errors, required, unsaved-changes, dirty, unread, search, search-highlight, custom-highlight-api, count, roll-up, next-issue, keyboard, aria-describedby, forms

When to use

  • A settings or profile form is split into tabs and errors, empty required fields or unsaved edits must not hide in a tab that is not selected
  • A search box filters a tabbed or sectioned page and each closed section should say how many matches it holds
  • A paginated review (import rows, moderation queue) must show which other pages still hold errors or duplicates
  • Native form validation silently fails because the first invalid field sits in a hidden tab or collapsed section

Avoid when

  • Sections nest several levels deep and the depth of each issue plus a path-opening walk-through matter most → use Pea instead
  • A tab only needs a fixed number the app already knows (12 notifications) → use Badge instead
  • The page must warn before leaving with unsaved work, not locate it → use Tell-Tale instead

インストール

node scripts/add.mjs inside-count --out ./src/marvelous

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

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

使い方

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

<mv-inside-count>
  <details><summary>Billing</summary><label>VAT number <input name="vat" required></label></details>
  <details><summary>Profile</summary><label>Name <input name="name" value="Ada"></label></details>
</mv-inside-count>

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

<div id="ic-demo" style="width:min(100%,54rem);margin-inline:auto">
  <style>
    #ic-demo { display:grid; gap:1.25rem; align-content:start; font-size:.875rem }
    #ic-demo .ic-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 }
    #ic-demo .ic-head { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
    #ic-demo .ic-title { display:grid; gap:.125rem; flex:1 1 12rem; min-width:0 }
    #ic-demo .ic-title strong { font-size:.9375rem; letter-spacing:-.01em }
    #ic-demo .ic-title span { color:var(--mv-fg-muted); font-size:.8125rem }
    #ic-demo .ic-search { width:13rem; max-width:100% }
    #ic-demo .ic-tools { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
    #ic-demo .ic-body { padding:1rem 1.125rem 1.125rem }
    #ic-demo mv-tabs [role="tabpanel"] { padding-top:.25rem }
    #ic-demo .ic-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,15rem),1fr)); gap:.875rem 1rem }
    #ic-demo .ic-note { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #ic-demo .ic-members { display:grid; margin:0; padding:0; list-style:none; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg) }
    #ic-demo .ic-members li { display:flex; align-items:center; gap:.75rem; padding:.625rem .75rem; font-size:.8125rem }
    #ic-demo .ic-members li + li { border-top:1px solid var(--mv-border) }
    #ic-demo .ic-av { display:grid; place-items:center; width:1.875rem; height:1.875rem; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.6875rem; font-weight:600; flex-shrink:0 }
    #ic-demo .ic-who { display:grid; flex:1; min-width:0 }
    #ic-demo .ic-who span { color:var(--mv-fg-muted); font-size:.75rem }
    #ic-demo .ic-switches { display:grid; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg) }
    #ic-demo .ic-switches .mv-choice { padding:.75rem .875rem }
    #ic-demo .ic-switches .mv-choice + .mv-choice { border-top:1px solid var(--mv-border) }
    #ic-demo .ic-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) }
    #ic-demo .ic-status { margin:0; color:var(--mv-fg-muted); font-size:.75rem; flex:1 1 16rem }
    #ic-demo .ic-status b { color:var(--mv-fg); font-weight:600 }
    #ic-demo .ic-opts { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap; font-size:.8125rem }
    #ic-demo .ic-rows { display:grid; margin:0; padding:0; list-style:none }
    #ic-demo .ic-rows li { display:grid; grid-template-columns:2rem minmax(0,1.1fr) minmax(0,1.4fr) 9rem; align-items:center; gap:.75rem; padding:.5rem 1.125rem; font-size:.8125rem; border-bottom:1px solid var(--mv-border) }
    #ic-demo .ic-rows li > .mv-badge { justify-self:end }
    #ic-demo .ic-rows li > span:first-child { color:var(--mv-fg-subtle); font-variant-numeric:tabular-nums }
    #ic-demo .ic-rows li > span:nth-child(3) { color:var(--mv-fg-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #ic-demo .ic-pager { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; padding:.75rem 1.125rem 1rem }
    #ic-demo .ic-pager .mv-pagination ul { gap:.625rem }
    #ic-demo kbd { font-size:.6875rem }
    #ic-demo mv-tabs [role="tablist"] { overflow-x:auto; scrollbar-width:none }
    @media (max-width:40rem) { #ic-demo .ic-rows li { grid-template-columns:1.5rem minmax(0,1fr) auto } #ic-demo .ic-rows li > span:nth-child(3) { display:none } }
  </style>

  <!-- 1 · Settings form: every closed tab (and the closed “Tax details” section inside Billing) tells what it holds -->
  <mv-inside-count id="ic-settings" query-from="#ic-search">
    <form class="ic-card" id="ic-form" aria-labelledby="ic-form-title">
      <div class="ic-head">
        <span class="ic-title">
          <strong id="ic-form-title">Workspace settings</strong>
          <span>Acme Labs · Business plan</span>
        </span>
        <span class="ic-tools">
          <span class="mv-input-group ic-search" data-size="sm">
            <label class="mv-input-group-addon" for="ic-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><span class="mv-sr-only">Search settings</span></label>
            <input class="mv-input" id="ic-search" type="search" value="invoice" placeholder="Search settings" autocomplete="off">
          </span>
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-inside-next>
            Next issue <kbd class="mv-kbd-group"><kbd class="mv-kbd">Alt</kbd><kbd class="mv-kbd">⇧</kbd><kbd class="mv-kbd">N</kbd></kbd>
          </button>
          <button type="submit" class="mv-button" data-size="sm">Save changes</button>
        </span>
      </div>

      <div class="ic-body">
        <mv-tabs data-variant="underline" value="general">
          <div role="tablist" aria-label="Settings sections">
            <button role="tab" data-value="general">General</button>
            <button role="tab" data-value="billing">Billing</button>
            <button role="tab" data-value="team">Team</button>
            <button role="tab" data-value="notifications">Notifications</button>
          </div>

          <div role="tabpanel" data-value="general">
            <div class="ic-grid">
              <div class="mv-field">
                <label class="mv-label" for="ic-name" data-required>Workspace name</label>
                <input class="mv-input" id="ic-name" name="name" value="Acme Labs" required>
              </div>
              <div class="mv-field">
                <label class="mv-label" for="ic-tz">Time zone</label>
                <select class="mv-input" id="ic-tz" name="tz">
                  <option>America/New_York (UTC−04:00)</option>
                  <option>Europe/London (UTC+01:00)</option>
                  <option>Asia/Tokyo (UTC+09:00)</option>
                </select>
              </div>
            </div>
            <p class="ic-note">Type in a field of another tab, clear a required one, or search: the closed tabs update as you go.</p>
          </div>

          <div role="tabpanel" data-value="billing">
            <div class="ic-grid">
              <div class="mv-field">
                <label class="mv-label" for="ic-bill-email" data-required>Billing email</label>
                <input class="mv-input" id="ic-bill-email" name="billingEmail" type="email" required placeholder="[email protected]">
              </div>
              <div class="mv-field">
                <label class="mv-label" for="ic-prefix">Invoice prefix</label>
                <input class="mv-input" id="ic-prefix" name="prefix" value="NWL-">
              </div>
            </div>
            <div class="mv-accordion" data-variant="bordered" style="margin-top:1rem">
              <details class="mv-accordion-item">
                <summary>Tax details</summary>
                <div class="mv-accordion-content">
                  <div class="mv-field">
                    <label class="mv-label" for="ic-vat">VAT number</label>
                    <input class="mv-input" id="ic-vat" name="vat" value="GB12345" pattern="GB\d{9}" aria-invalid="true" aria-describedby="ic-vat-err">
                    <p class="mv-field-error" id="ic-vat-err">A UK VAT number is GB followed by 9 digits, for example GB123456789.</p>
                  </div>
                </div>
              </details>
            </div>
            <p class="ic-note">Invoices are emailed on the 1st of each month, in USD.</p>
          </div>

          <div role="tabpanel" data-value="team">
            <ul class="ic-members">
              <li><span class="ic-av" aria-hidden="true">EC</span><span class="ic-who"><strong>Emma Clarke</strong><span>Owner · [email protected]</span></span></li>
              <li data-new><span class="ic-av" aria-hidden="true">KT</span><span class="ic-who"><strong>Kenji Tanaka</strong><span>Invited Sep 22 · [email protected]</span></span><span class="mv-badge" data-variant="info">New</span></li>
              <li data-new><span class="ic-av" aria-hidden="true">AO</span><span class="ic-who"><strong>Amara Okafor</strong><span>Invited Sep 23 · [email protected]</span></span><span class="mv-badge" data-variant="info">New</span></li>
            </ul>
          </div>

          <div role="tabpanel" data-value="notifications">
            <div class="ic-switches">
              <label class="mv-choice" data-control="end"><input type="checkbox" role="switch" class="mv-switch" name="nPaid" checked><span class="mv-choice-text"><span class="mv-choice-title">Invoice paid</span><span class="mv-choice-description">Email the billing contact when a payment clears</span></span></label>
              <label class="mv-choice" data-control="end"><input type="checkbox" role="switch" class="mv-switch" name="nWeekly" id="ic-weekly"><span class="mv-choice-text"><span class="mv-choice-title">Weekly usage report</span><span class="mv-choice-description">Every Monday at 9:00 a.m.</span></span></label>
              <label class="mv-choice" data-control="end"><input type="checkbox" role="switch" class="mv-switch" name="nSecurity" checked><span class="mv-choice-text"><span class="mv-choice-title">Security alerts</span><span class="mv-choice-description">New sign-ins and API key changes</span></span></label>
            </div>
          </div>
        </mv-tabs>
      </div>

      <div class="ic-foot">
        <p class="ic-status" id="ic-status" aria-live="off">…</p>
        <span class="ic-opts">
          <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="ic-compact" data-inside-ignore> Compact counts</label>
          <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="ic-reveal" data-inside-ignore checked> Open first error on save</label>
        </span>
      </div>
    </form>
  </mv-inside-count>

  <!-- 2 · Paginated import review: page numbers carry a corner count of what the other pages hold -->
  <mv-inside-count id="ic-import" style="--mv-inside-count-duplicate:var(--mv-warning)" display="corner" kinds="error duplicate" next-kinds="error duplicate" shortcut="none">
    <section class="ic-card" aria-labelledby="ic-import-title">
      <div class="ic-head">
        <span class="ic-title">
          <strong id="ic-import-title">Review contacts import</strong>
          <span>contacts-sept.csv · 12 rows · 3 pages</span>
        </span>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-inside-next>Show next page with issues</button>
      </div>
      <div id="ic-p1">
        <ul class="ic-rows">
          <li><span>1</span><strong>Lucas Silva</strong><span>[email protected]</span><span class="mv-badge" data-variant="success">Ready</span></li>
          <li><span>2</span><strong>Sofia Rossi</strong><span>[email protected]</span><span class="mv-badge" data-variant="success">Ready</span></li>
          <li data-issue><span>3</span><strong>Omar Haddad</strong><span>omar.haddad@</span><span class="mv-badge" data-variant="danger">Invalid email</span></li>
          <li><span>4</span><strong>Priya Natarajan</strong><span>[email protected]</span><span class="mv-badge" data-variant="success">Ready</span></li>
        </ul>
      </div>
      <div id="ic-p2" hidden>
        <ul class="ic-rows">
          <li data-issue><span>5</span><strong>Chen Wei</strong><span>-</span><span class="mv-badge" data-variant="danger">Missing email</span></li>
          <li><span>6</span><strong>Hannah Becker</strong><span>[email protected]</span><span class="mv-badge" data-variant="success">Ready</span></li>
          <li data-inside="duplicate"><span>7</span><strong>Sofia Rossi</strong><span>[email protected]</span><span class="mv-badge" data-variant="warning">Duplicate of row 2</span></li>
          <li data-issue><span>8</span><strong>Mateo García</strong><span>mateo@garcia</span><span class="mv-badge" data-variant="danger">Invalid email</span></li>
        </ul>
      </div>
      <div id="ic-p3" hidden>
        <ul class="ic-rows">
          <li><span>9</span><strong>Aisha Bello</strong><span>[email protected]</span><span class="mv-badge" data-variant="success">Ready</span></li>
          <li data-inside="duplicate"><span>10</span><strong>Lucas Silva</strong><span>[email protected]</span><span class="mv-badge" data-variant="warning">Duplicate of row 1</span></li>
          <li><span>11</span><strong>Noah Williams</strong><span>[email protected]</span><span class="mv-badge" data-variant="success">Ready</span></li>
          <li><span>12</span><strong>Yuki Sato</strong><span>[email protected]</span><span class="mv-badge" data-variant="success">Ready</span></li>
        </ul>
      </div>
      <div class="ic-pager">
        <nav class="mv-pagination" aria-label="Import pages">
          <ul id="ic-pages">
            <li><button type="button" class="mv-pagination-item" data-inside-for="ic-p1" data-page="1" aria-current="page">1</button></li>
            <li><button type="button" class="mv-pagination-item" data-inside-for="ic-p2" data-page="2">2</button></li>
            <li><button type="button" class="mv-pagination-item" data-inside-for="ic-p3" data-page="3">3</button></li>
          </ul>
        </nav>
        <p class="ic-status" id="ic-import-status" style="flex:0 1 auto">…</p>
      </div>
    </section>
  </mv-inside-count>

  <script type="module">
    await Promise.all(["mv-inside-count", "mv-tabs"].map((t) => customElements.whenDefined(t)));
    const settings = document.getElementById("ic-settings");
    const status = document.getElementById("ic-status");
    const form = document.getElementById("ic-form");

    // A switch toggled earlier in this session: unsaved, in a closed tab.
    document.getElementById("ic-weekly").checked = true;
    settings.refresh();

    const phrase = (totals, strings) => {
      const parts = Object.entries(totals).map(([k, n]) => {
        const [one, many = one] = String(strings[`${k}Long`] ?? strings[k] ?? k).split("|");
        return `${n} ${n === 1 ? one : many}`;
      });
      return parts;
    };
    const renderStatus = (totals) => {
      const parts = phrase(totals, settings.strings);
      status.replaceChildren();
      if (!parts.length) { status.textContent = "Nothing hidden in closed sections."; return; }
      const b = document.createElement("b");
      b.textContent = "In closed sections: ";
      status.append(b, parts.join(" · "));
    };
    settings.addEventListener("mv-inside-change", (e) => renderStatus(e.detail.totals));
    renderStatus(settings.totals);

    form.addEventListener("submit", (e) => {
      e.preventDefault();
      settings.markSaved();
      status.textContent = `Saved at ${new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}.`;
    });
    const compact = document.getElementById("ic-compact");
    compact.addEventListener("change", () => { settings.display = compact.checked ? "compact" : "full"; });
    // Narrow screens: glyphs and numbers only, so the four tabs still fit.
    if (matchMedia("(max-width: 40rem)").matches) { compact.checked = true; settings.display = "compact"; }
    document.getElementById("ic-reveal").addEventListener("change", (e) => { settings.revealOnSubmit = e.target.checked; });
    settings.revealOnSubmit = true;

    // Import review: plain page switching, the component reads what each page holds.
    const imp = document.getElementById("ic-import");
    const impStatus = document.getElementById("ic-import-status");
    imp.strings = { duplicate: "duplicate|duplicates", duplicateLong: "duplicate row|duplicate rows" };
    const pages = document.getElementById("ic-pages");
    pages.addEventListener("click", (e) => {
      const btn = e.target.closest("[data-page]");
      if (!btn) return;
      for (const b of pages.querySelectorAll("[data-page]")) {
        const on = b === btn;
        if (on) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current");
        document.getElementById(b.getAttribute("data-inside-for")).hidden = !on;
      }
    });
    const impRender = (totals) => {
      const parts = phrase(totals, imp.strings);
      impStatus.textContent = parts.length ? `Other pages: ${parts.join(" · ")}` : "No issues on other pages.";
    };
    imp.addEventListener("mv-inside-change", (e) => impRender(e.detail.totals));
    impRender(imp.totals);
  </script>
</div>

API

Attributes

NameタイプDefaultDescription
kindsspace-separated listerror required change new matchKinds shown, in priority order (first = most important, sets the pill tint and comes first). Kinds found in the content but not listed (custom data-inside kinds, signals) are appended after.
querystringSearch text counted as match in closed regions and highlighted everywhere inside the component (case- and accent-insensitive). Ignored while query-from is bound.
query-fromCSS selectorAn input (inside or outside the component) whose value is used as the query, live on every input event. The field itself is never counted as a change.
min-querynumber2Shortest query that is searched.
displayfull | compact | cornerfullfull: glyph, number and word for the first limit kinds, glyph and number for the others. compact: glyph and number only. corner: a small counter pinned on the trigger’s top-end corner with the top kind only (page numbers, icon buttons). data-inside-display on a trigger overrides it.
limitnumber2In full mode, how many kinds get a word (“2 errors”); the rest keep glyph and number.
showclosed | alwaysclosedalways also keeps a quieter pill on open triggers.
shortcutstring | noneAlt+Shift+NKey combination (focus inside the component) that opens the next closed region holding next-kinds and focuses its first issue. Matched on the key or the physical key code, so it works whatever the layout; none disables it.
next-kindsspace-separated listerror requiredKinds that make a region a target for the shortcut, [data-inside-next] buttons and openNext().
reveal-on-submitbooleanWhen a submission finds its first invalid field inside a closed region, open the regions on its path (cancelable mv-inside-open, source "submit") so the browser can focus it and show its message, as it would for a visible field.
data-inside-forids | CSS selectorOn a custom trigger (page number, step, column toggle): the panel(s) it stands for. Closed when every panel is hidden, inert, not rendered or scrolled entirely out of its scroll container; data-inside-closed="true | false" on the trigger forces the state.
data-insidespace-separated kindsOn any element: counts it as one signal of each kind (duplicate, warning, mention…). Name them with strings ("duplicate|duplicates") and tint them with --mv-inside-count-<kind>.
data-issue / data-new / data-unread / data-changedboolean | "false"On any element: counted as an error, a new item or an unsaved change. "false", "off" or "none" ignores it.
data-inside-ignorebooleanNothing inside is counted or highlighted; on a trigger, it is not a region.
data-inside-slotbooleanInside a trigger: where the pill is placed (default: appended to the trigger).
data-inside-labelstringOn a trigger: name used in announcements and events (default: aria-label, then its text).
data-inside-kindkindSet by the component on each trigger showing a pill: its most important kind. Style your own triggers with it.
data-inside-nextbooleanOn a button inside the component: opens the next region with issues. Gets aria-disabled when there is none, data-inside-pending (number of such regions) and aria-keyshortcuts.

Properties

NameタイプDescription
regionsArray<{ trigger, panels, closed, label, counts, total, depth }>Every region found, in document order, with its rolled-up counts (read-only snapshot).
totalsRecord<kind, number>Signals hidden in closed regions, each counted once (read-only).
signalsArray<{ kind, selector?, test?(el) }>Custom rules: every element of a region matching selector (and test) counts as one signal of kind.
stringsPartial<Record<string, string>>Overrides: <kind> (pill word, "singular|plural"), <kind>Long (description word), describe ({list}), hint ({shortcut}), announce ({count}, {where}), announceClear. English defaults; lists are joined with Intl.ListFormat in the nearest lang.
kinds / query / queryFrom / minQuery / display / limit / show / shortcut / nextKinds / revealOnSubmitreflectedMirror the attributes.

Methods

NameDescription
openNext({ kinds?, focus = true })Opens the next closed region holding kinds (default next-kinds) after the focused section, wrapping around, opens closed regions nested on the way, then focuses and scrolls to the first issue (a temporary tabindex="-1" for non-focusable rows). Returns Promise<Element | null>.
markSaved(root?)Commits the current values of the fields in root (default: the whole component) as their defaults, so they stop counting as changes and a form reset returns to them. Clears data-changed. Returns how many fields were dirty.
refresh()Recomputes synchronously (normally batched per frame; needed after setting .value or .checked from code, which fires no event). Returns totals.
countsFor(trigger)Counts of one region (element or selector), or null.

Events

NameDescription
mv-inside-changeCounts or open states changed (also once on connection). detail: { regions, totals, reason: "init" | "input" | "submit" | "query" | "toggle" | "mutation" | "scroll" | "api" }.
mv-inside-openCancelable, before the component opens a region (shortcut, button, openNext or reveal-on-submit). detail: { trigger, panels, counts, label, source: "shortcut" | "button" | "api" | "submit" }. preventDefault() keeps it closed; open it your way if your tabs are state-driven.

Content structure

NameDescription
(content)Any structure with tabs, <details>, disclosures, tree items or data-inside-for triggers. The component only adds a pill (aria-hidden, data-inside-own) inside closed triggers, an id to their aria-describedby, data-inside-kind, and a hidden description container; everything else stays yours.

CSS classes

NameDescription
mv-inside-countThe pill on a trigger (aria-hidden). data-kind = most important kind, data-display, data-state="closed | open".
mv-inside-count-segOne kind in the pill: .mv-inside-count-icon (glyph drawn with a mask), .mv-inside-count-num, .mv-inside-count-word (full mode). data-kind.

CSS variables

NameDefaultDescription
--mv-inside-count-errorvar(--mv-danger)Tint of errors (exclamation glyph).
--mv-inside-count-requiredvar(--mv-fg-muted)Tint of required fields left empty (asterisk).
--mv-inside-count-changevar(--mv-info)Tint of unsaved changes (pencil).
--mv-inside-count-newvar(--mv-accent)Tint of new or unread items (spark).
--mv-inside-count-matchvar(--mv-warning)Tint of search matches (magnifier). Custom kinds read --mv-inside-count-<kind> (ring glyph).
--mv-inside-count-match-bgsoft yellow (light) / amber 40% (dark)Background of highlighted search matches (::highlight(mv-inside-match)).

Accessibility

Nothing relies on color: each kind has its own glyph next to its number, and in full mode a word (“2 errors”). The pill is aria-hidden so it never changes the trigger’s accessible name; instead the trigger’s aria-describedby gains a hidden description (“Contains 1 error, 1 required field left empty and 2 search matches. Press Alt+Shift+N to open it.”), removed as soon as the region opens or empties, so a screen reader hears it when focusing the tab, summary or button. Focus is never moved on its own: counts update silently while typing, and the only announcement is a polite one after a submission, and only when the errors hidden in closed sections changed (“2 errors in closed sections: Billing (1) and Tax details (1).”, then “No errors left in closed sections.”). Focus moves only on an explicit request: the shortcut or a [data-inside-next] button (aria-keyshortcuts, aria-disabled when nothing is left) opens the next region and focuses its first issue, and reveal-on-submit opens the path to the first invalid field so the browser focuses it, exactly as native validation does for a visible field. Search matches use the CSS Custom Highlight API: the DOM text is never rewritten. Reduced motion (OS or data-motion="reduce"): pills appear without rising, numbers change without a pop, scrolling is instant. Forced colors: the pill uses Canvas / CanvasText with a thicker border for errors, matches use Mark / MarkText.

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