ExklusivBeta
Inside Count <mv-inside-count>
Geschlossene Tabs, Akkordeons und Seiten, die sagen, was sie enthalten, bevor du sie öffnest. Umschließe einen Container (ein Einstellungsformular in Tabs, ein Akkordeon, einen Stepper, eine einklappbare Sidebar, eine paginierte Tabelle), und jeder geschlossene Bereich erhält an seinem Auslöser eine kleine beschriftete Zahl, direkt aus dem Inhalt gelesen, ohne dass du etwas von Hand markieren musst: Fehler (aria-invalid, [data-issue], native Constraint Validation, sobald ein Feld geändert oder sein Formular abgeschickt wurde), noch leere Pflichtfelder, ungespeicherte Änderungen (Wert, checked oder Auswahl weicht vom Standard ab), neue oder ungelesene Einträge ([data-new], [data-unread]), Suchtreffer für eine Abfrage oder ein verknüpftes Suchfeld (unabhängig von Akzenten und Groß-/Kleinschreibung, in offenen wie geschlossenen Panels mit der CSS Custom Highlight API hervorgehoben), dazu eigene Arten über data-inside="kind" oder signals-Regeln. Mehrere Arten teilen sich eine kompakte Pille in Prioritätsreihenfolge („1 error · 1 to fill · 2“), mit einem Glyph pro Art; die Modi compact und corner passen auf kleine Auslöser wie Seitenzahlen. Eingebaute Adapter decken <details>, [role=tab] + aria-controls, Disclosures mit [aria-expanded] + aria-controls, Baumeinträge mit einer Kindgruppe und data-inside-for für alles andere ab (eine Seite, eine aus ihrem Container gescrollte Spalte). Die Zahlen summieren sich auf (ein geschlossener Abschnitt addiert seine geschlossenen Unterabschnitte, auch Tab-Panels, die woanders liegen), aktualisieren sich live bei input, change, invalid, reset und DOM-Änderungen, und Alt+Shift+N oder ein beliebiger [data-inside-next]-Button öffnet den nächsten geschlossenen Bereich mit Fehlern, öffnet unterwegs verschachtelte Bereiche und fokussiert das erste Problem. reveal-on-submit behebt native Validierung in versteckten Tabs: Der Abschnitt mit dem ersten ungültigen Feld öffnet sich, damit der Browser es fokussieren kann. markSaved() übernimmt die aktuellen Werte als sauberen Zustand.
| Kategorie | Navigation |
|---|---|
| Typ | Web Component (<mv-inside-count>) |
| Status | Beta |
| Kit | Formulare, die niemanden verlieren |
| 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
Installation
node scripts/add.mjs inside-count --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["inside-count"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Kopierte Dateien (inklusive Abhängigkeiten): 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.
Verwendung
Schnellstart mit dem kleinsten funktionierenden Markup:
<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>Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<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>Northwind 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="Northwind 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>Kulturelle Referenz
Minesweeper, Microsoft (Robert Donner & Curt Johnson, nach früheren Minensuchspielen) (1990, Spiel). Jede Zahl auf dem Spielfeld gibt an, wie viele Minen in den geschlossenen Feldern daneben liegen, sodass der Spieler entscheidet, wo er aufdeckt, ohne alles aufzudecken. In der UI zeigt jeder geschlossene Tab, jeder Abschnitt und jede Seite an ihrem Auslöser, wie viele Fehler, leere Pflichtfelder, ungespeicherte Änderungen, neue Einträge oder Suchtreffer sich darin verbergen, und ein Shortcut öffnet den nächsten Bereich, der Aufmerksamkeit braucht.
API
Attributes
| Name | Typ | 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 | Typ | 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.