独家

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>)
状态稳定版
套件不让用户流失的表单
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 翻译。报告翻译问题