エクスクルーシブ
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>) |
| ステータス | 安定版 |
| キット | ユーザーを取りこぼさないフォーム |
| Keywords | exclusive, 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/marvelousMarvelous 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 | タイプ | Default | Description |
|---|---|---|---|
kinds | space-separated list | error required change new match | Kinds 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. |
query | string | Search text counted as match in closed regions and highlighted everywhere inside the component (case- and accent-insensitive). Ignored while query-from is bound. | |
query-from | CSS selector | An 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-query | number | 2 | Shortest query that is searched. |
display | full | compact | corner | full | full: 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. |
limit | number | 2 | In full mode, how many kinds get a word (“2 errors”); the rest keep glyph and number. |
show | closed | always | closed | always also keeps a quieter pill on open triggers. |
shortcut | string | none | Alt+Shift+N | Key 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-kinds | space-separated list | error required | Kinds that make a region a target for the shortcut, [data-inside-next] buttons and openNext(). |
reveal-on-submit | boolean | When 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-for | ids | CSS selector | On 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-inside | space-separated kinds | On 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-changed | boolean | "false" | On any element: counted as an error, a new item or an unsaved change. "false", "off" or "none" ignores it. | |
data-inside-ignore | boolean | Nothing inside is counted or highlighted; on a trigger, it is not a region. | |
data-inside-slot | boolean | Inside a trigger: where the pill is placed (default: appended to the trigger). | |
data-inside-label | string | On a trigger: name used in announcements and events (default: aria-label, then its text). | |
data-inside-kind | kind | Set by the component on each trigger showing a pill: its most important kind. Style your own triggers with it. | |
data-inside-next | boolean | On 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 |
|---|---|---|
regions | Array<{ trigger, panels, closed, label, counts, total, depth }> | Every region found, in document order, with its rolled-up counts (read-only snapshot). |
totals | Record<kind, number> | Signals hidden in closed regions, each counted once (read-only). |
signals | Array<{ kind, selector?, test?(el) }> | Custom rules: every element of a region matching selector (and test) counts as one signal of kind. |
strings | Partial<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 / revealOnSubmit | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-inside-change | Counts or open states changed (also once on connection). detail: { regions, totals, reason: "init" | "input" | "submit" | "query" | "toggle" | "mutation" | "scroll" | "api" }. |
mv-inside-open | Cancelable, 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
| Name | Description |
|---|---|
(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
| Name | Description |
|---|---|
mv-inside-count | The pill on a trigger (aria-hidden). data-kind = most important kind, data-display, data-state="closed | open". |
mv-inside-count-seg | One 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
| Name | Default | Description |
|---|---|---|
--mv-inside-count-error | var(--mv-danger) | Tint of errors (exclamation glyph). |
--mv-inside-count-required | var(--mv-fg-muted) | Tint of required fields left empty (asterisk). |
--mv-inside-count-change | var(--mv-info) | Tint of unsaved changes (pencil). |
--mv-inside-count-new | var(--mv-accent) | Tint of new or unread items (spark). |
--mv-inside-count-match | var(--mv-warning) | Tint of search matches (magnifier). Custom kinds read --mv-inside-count-<kind> (ring glyph). |
--mv-inside-count-match-bg | soft 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.