Exclusifbêta

Inside Count <mv-inside-count>

Des onglets, accordéons et pages fermés qui disent ce qu’ils contiennent avant que vous ne les ouvriez. Enveloppez un conteneur (un formulaire de réglages en onglets, un accordéon, un stepper, une barre latérale repliable, un tableau paginé) et chaque région fermée reçoit un petit compteur libellé sur son déclencheur, lu dans le contenu lui-même, sans rien à signaler à la main : erreurs (aria-invalid, [data-issue], validation native par contraintes dès qu’un champ a été modifié ou son formulaire envoyé), champs obligatoires encore vides, modifications non enregistrées (value, checked ou sélection différente de la valeur par défaut), éléments nouveaux ou non lus ([data-new], [data-unread]), correspondances de recherche pour une requête ou un champ de recherche lié (insensibles aux accents et à la casse, surlignées dans les panneaux ouverts comme fermés grâce à la CSS Custom Highlight API), plus des types personnalisés via data-inside="kind" ou des règles signals. Plusieurs types partagent une même pastille compacte, par ordre de priorité (« 1 erreur · 1 à remplir · 2 »), avec un glyphe par type ; les modes compact et corner conviennent aux petits déclencheurs comme les numéros de page. Des adaptateurs intégrés couvrent <details>, [role=tab] + aria-controls, les disclosures [aria-expanded] + aria-controls, les éléments d’arbre avec un groupe enfant, et data-inside-for pour tout le reste (une page, une colonne sortie de son conteneur par le défilement). Les compteurs se cumulent (une section fermée additionne ses sous-sections fermées, y compris les panneaux d’onglets situés ailleurs), se mettent à jour en direct sur input, change, invalid, reset et les changements du DOM, et Alt+Shift+N ou n’importe quel bouton [data-inside-next] ouvre la région fermée suivante qui contient des erreurs, en ouvrant au passage les régions imbriquées, et focalise le premier problème. reveal-on-submit corrige la validation native dans les onglets masqués : la section qui contient le premier champ invalide s’ouvre pour que le navigateur puisse le focaliser. markSaved() fixe les valeurs actuelles comme état de référence.

CatégorieNavigation
TypeWeb Component (<mv-inside-count>)
Statutbêta
KitDes formulaires qui ne perdent personne
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

Installation

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

Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["inside-count"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : 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.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<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>

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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>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>

Référence culturelle

Démineur, Microsoft (Robert Donner & Curt Johnson, d’après des jeux de chasse aux mines antérieurs) (1990, jeu). Chaque nombre du plateau indique combien de mines se cachent dans les cases fermées voisines : le joueur décide où ouvrir sans tout ouvrir. Dans l’interface, chaque onglet, section ou page fermé indique sur son déclencheur combien d’erreurs, de champs obligatoires vides, de modifications non enregistrées, de nouveaux éléments ou de correspondances de recherche il cache, et un raccourci ouvre le suivant qui demande votre attention.

API

Attributes

NameTypeDefaultDescription
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

NameTypeDescription
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.

Cette page a été traduite par IA. Signaler un problème de traduction