独家
Inside Count <mv-inside-count>
关闭状态的标签页、手风琴和页面,在打开之前就告诉你里面有什么。
文化出处
《扫雷》,Microsoft(Robert Donner 和 Curt Johnson,借鉴了更早的扫雷类游戏)(1990 年,游戏)。棋盘上的每个数字表示其相邻未打开的格子里藏着多少颗地雷,玩家据此决定打开哪里,而不必把所有格子都打开。在界面中,每个关闭的标签页、区段或页面都会在其触发器上显示它隐藏了多少错误、未填写的必填字段、未保存的更改、新条目或搜索匹配,并且有一个快捷键可以打开下一个需要处理的区域。
工作原理
让关闭的标签页、手风琴和页面在打开之前就说明里面有什么。包裹一个容器(分标签页的设置表单、手风琴、步骤条、可折叠侧边栏、分页表格),每个关闭的区域都会在其触发器上获得一个带标签的小计数,直接从内容本身读取,无需手动标记:错误(aria-invalid、[data-issue],以及字段被修改或表单提交后的原生约束校验)、仍为空的必填字段、未保存的更改(value、checked 或选中项与默认值不同)、新的或未读的条目([data-new]、[data-unread])、某个查询或绑定搜索框的搜索匹配(不区分重音和大小写,借助 CSS Custom Highlight API 在打开和关闭的面板中同样高亮),还可以通过 data-inside="kind" 或信号规则添加自定义类型。多种类型按优先级顺序共用一个紧凑的胶囊(“1 error · 1 to fill · 2”),每种类型有一个图标;紧凑模式和角标模式适合页码这类小触发器。内置适配器支持 <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/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 | 类型 | 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.