Exclusifbêta

Snowball <mv-snowball>

Sélection par balayage pour les grilles et les listes (photothèques, gestionnaires de fichiers, boîtes de réception, catalogues) : maintenez Maj (ou n’importe quel modificateur, ou aucun) et faites rouler le pointeur sur les éléments. C’est le tracé, et non un rectangle, qui décide de ce qui est pris : vous pouvez serpenter entre des photos éparpillées sans accrocher leurs voisines. Chaque élément touché se colle à une boule de sélection qui suit le pointeur. L’élément donne un petit coup magnétique, une miniature vole jusqu’à la boule et roule avec elle en 3D pendant que vous bougez, et le compteur se loge à l’intérieur. La boule grossit avec la sélection et porte un peu plus loin à mesure qu’elle grandit. Maintenir Alt pendant le glissement désélectionne, et le mode peinture laisse le premier élément décider. Échap annule tout le balayage. Au relâchement, la boule tombe dans un plateau fixe qui regroupe vos actions groupées (slot="actions", avec un événement mv-action), plus Tout sélectionner et Effacer. Au toucher, c’est un appui long puis un glissement, avec défilement automatique aux bords du conteneur de défilement. Le clavier est pris en charge à part entière : Maj + flèches parcourt une grille ou une liste, revenir en arrière désélectionne, Espace bascule la sélection, Ctrl/Cmd+A sélectionne tout. Les éléments conservent une vraie sémantique (aria-selected sur les options ou les gridcells, ou la case à cocher qu’ils contiennent, dont les événements input/change se déclenchent toujours). Le hit-testing utilise des rects mis en cache, combinés aux décalages de défilement, et la boule tourne dans une frameLoop qui se met en veille quand rien ne bouge. Préférez un lasso rectangulaire pour les canevas libres sélectionnés par zone, et de simples cases à cocher pour une poignée d’éléments.

CatégorieAffichage de données
TypeWeb Component (<mv-snowball>)
Statutbêta
KitNavigation pour utilisateurs avancés
Keywordsexclusive, culture, selection, multi-select, sweep-select, bulk-actions, batch, photo-grid, file-manager, gallery, listbox, gesture, touch, long-press, keyboard, lasso-alternative, checkbox, tray, auto-scroll

When to use

  • A photo library or file manager needs to pick dozens of scattered items in one gesture instead of clicking each checkbox
  • Items sit in a dense gri j where a rectangle lasso would also catc h the unwanted neighbors between the min
  • People must select many items on a phone, where a long-press then drag beats tapping them one by one
  • Bulk actions such as delete, move or download should appear in a sticky tray right where the selection happens

Avoid when

  • Rows are tabular records that need sorting, filtering and Shift + click ranges → use Data table instead
  • There are only a handful of options, where plain checkboxes are clearer → use Checkbox instead
  • Items must also be dragged to reorder or move between columns: the two gestures would compete → use Kanban instead

Installation

node scripts/add.mjs snowball --out ./src/marvelous

Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["snowball"], 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, core/observe.js, components/snowball/snowball.js, components/snowball/snowball.css.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<mv-snowball>
  <ul role="listbox" aria-label="Photos" aria-multiselectable="true">
    <li role="option" data-value="IMG_4101">Harbor</li><li role="option" data-value="IMG_4102">Beach</li><li role="option" data-value="IMG_4103">Glacier</li>
  </ul>
</mv-snowball>

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :

<div id="sb-demo">
  <style>
    #sb-demo { display:grid; gap:1.25rem; width:min(100%,58rem); margin-inline:auto; align-content:start }
    #sb-demo .sb-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #sb-demo .sb-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #sb-demo .sb-title { display:grid; gap:.125rem; min-width:0 }
    #sb-demo .sb-title strong { font-size:.9375rem; letter-spacing:-.01em }
    #sb-demo .sb-title span { color:var(--mv-fg-muted); font-size:.8125rem }
    #sb-demo .sb-hint { display:flex; align-items:center; gap:.375rem; flex-wrap:wrap; margin:0; color:var(--mv-fg-muted); font-size:.75rem }
    #sb-demo .sb-hint i { font-style:normal; color:var(--mv-fg-subtle) }

    /* Photo grid */
    #sb-demo .sb-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(8.25rem,1fr)); gap:.5rem; margin:0; padding:1rem 1.25rem 0; list-style:none }
    #sb-demo .sb-grid li { position:relative; aspect-ratio:4/3; overflow:hidden; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); transition:background-color var(--mv-duration-fast) }
    #sb-demo .sb-grid img { display:block; width:100%; height:100%; object-fit:cover; transition:scale var(--mv-duration-normal) var(--mv-ease-out), border-radius var(--mv-duration-normal) var(--mv-ease-out); -webkit-user-drag:none }
    #sb-demo .sb-grid li[aria-selected="true"] { background:var(--mv-accent-subtle) }
    #sb-demo .sb-grid li[aria-selected="true"] img { scale:.84; border-radius:var(--mv-radius-sm) }
    #sb-demo .sb-tick { position:absolute; top:.375rem; left:.375rem; width:1.125rem; height:1.125rem; box-sizing:border-box; border:2px solid oklch(1 0 0 / .95); border-radius:50%; background:oklch(0 0 0 / .18); opacity:0; transition:opacity var(--mv-duration-fast), background-color var(--mv-duration-fast) }
    #sb-demo .sb-tick::after { content:""; position:absolute; left:4px; top:1px; width:4px; height:8px; border:solid oklch(1 0 0); border-width:0 2px 2px 0; rotate:45deg; opacity:0 }
    #sb-demo .sb-grid li:hover .sb-tick, #sb-demo .sb-grid li:focus-visible .sb-tick, #sb-demo mv-snowball[data-ready] .sb-tick, #sb-demo mv-snowball[data-has-selection] .sb-tick { opacity:1 }
    #sb-demo .sb-grid li[aria-selected="true"] .sb-tick { opacity:1; border-color:var(--mv-accent); background:var(--mv-accent) }
    #sb-demo .sb-grid li[aria-selected="true"] .sb-tick::after { opacity:1 }

    #sb-demo .sb-controls { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; margin-top:1rem; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
    #sb-demo .sb-field { display:flex; align-items:center; gap:.625rem; font-size:.8125rem; color:var(--mv-fg-muted) }
    #sb-demo .sb-log { flex:1 1 14rem; min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); text-align:end }

    /* File picker */
    #sb-files { --mv-snowball-outline:none }
    #sb-demo .sb-files { margin:0; padding:.375rem .5rem 0; list-style:none }
    #sb-demo .sb-file label { display:grid; grid-template-columns:auto auto minmax(0,1fr) 4.5rem 6.5rem; align-items:center; gap:.75rem; padding:.5625rem .75rem; border-radius:var(--mv-radius-md); font-size:.8125rem; cursor:default; transition:background-color var(--mv-duration-fast) }
    #sb-demo .sb-file label:hover { background:var(--mv-bg-muted) }
    #sb-demo .sb-file[data-selected] label { background:var(--mv-accent-subtle) }
    #sb-demo .sb-file svg { width:1.25rem; height:1.25rem }
    #sb-demo .sb-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500 }
    #sb-demo .sb-meta, #sb-demo .sb-date { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums; text-align:end; white-space:nowrap }
    #sb-demo .sb-files-foot { margin:0; padding:.25rem 1.25rem 1rem; color:var(--mv-fg-subtle); font-size:.75rem; min-height:1rem }
    @media (max-width:40rem) {
      #sb-demo .sb-file label { grid-template-columns:auto auto minmax(0,1fr) 4.5rem }
      #sb-demo .sb-date { display:none }
    }
  </style>

  <!-- 1. Photo library: Shift + drag rolls through the grid, the ball drops into a tray of bulk actions -->
  <section class="sb-card" aria-labelledby="sb-photos-title">
    <header class="sb-head">
      <span class="sb-title">
        <strong id="sb-photos-title">Iceland · Ring Road</strong>
        <span>Sep 12-19, 2026 · 18 photos</span>
      </span>
      <p class="sb-hint"><kbd class="mv-kbd">Shift</kbd> + drag to roll across photos <i>·</i> <kbd class="mv-kbd">Alt</kbd> drops <i>·</i> long-press on touch</p>
    </header>

    <mv-snowball id="sb-photos" items="[role='option']">
      <ul class="sb-grid" role="listbox" aria-labelledby="sb-photos-title">
        <li role="option" data-value="IMG_4101" data-size="4.2" aria-selected="false"><img alt="Harbor at dawn, Reykjavík" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4102" data-size="3.8" aria-selected="true"><img alt="Black sand beach under cloud" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4103" data-size="5.1" aria-selected="false"><img alt="Glacier lagoon with floating ice" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4104" data-size="6.4" aria-selected="false"><img alt="Northern lights over the hills" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4105" data-size="3.3" aria-selected="false"><img alt="Lava field in morning haze" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4106" data-size="4.7" aria-selected="true"><img alt="Fjord road along the water" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4107" data-size="3.9" aria-selected="false"><img alt="Geothermal pool and steam" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4108" data-size="4.4" aria-selected="false"><img alt="Basalt cliffs at low tide" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4109" data-size="5.6" aria-selected="false"><img alt="Sunset over the western coast" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4110" data-size="4.0" aria-selected="false"><img alt="Waterfall in a green valley" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4111" data-size="3.6" aria-selected="false"><img alt="Highland ridges at dusk" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4112" data-size="4.9" aria-selected="true"><img alt="Sea cliffs and green slopes" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4113" data-size="5.2" aria-selected="false"><img alt="City lights at blue hour" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4114" data-size="6.1" aria-selected="false"><img alt="Inside a blue ice cave" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4115" data-size="3.4" aria-selected="false"><img alt="Empty road through golden plains" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4116" data-size="5.8" aria-selected="false"><img alt="Volcano glowing at night" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4117" data-size="3.7" aria-selected="false"><img alt="Moss-covered lava rocks" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
        <li role="option" data-value="IMG_4118" data-size="4.5" aria-selected="false"><img alt="Iceberg on a calm bay" width="400" height="300"><span class="sb-tick" aria-hidden="true"></span></li>
      </ul>
      <button slot="actions" type="button" class="mv-button" data-variant="outline" data-size="sm" data-snowball-action="download">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>
        Download
      </button>
      <button slot="actions" type="button" class="mv-button" data-variant="outline" data-size="sm" data-snowball-action="album">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16v12H4zM12 9v6M9 12h6"/></svg>
        Add to album
      </button>
      <button slot="actions" type="button" class="mv-button" data-variant="destructive" data-size="sm" data-snowball-action="delete">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
        Delete
      </button>
    </mv-snowball>

    <div class="sb-controls">
      <span class="sb-field">
        <span id="sb-mode-label">Sweep</span>
        <mv-segmented id="sb-mode" aria-labelledby="sb-mode-label" value="collect">
          <button value="collect">Collect</button>
          <button value="paint">Paint</button>
        </mv-segmented>
      </span>
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="sb-play">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 5v14l11-7z"/></svg>
        Replay a sweep
      </button>
      <p class="sb-log" id="sb-log">3 photos selected · 13.4 MB</p>
    </div>
  </section>

  <!-- 2. File picker: modifier="none", any drag across rows picks them; checkboxes stay the source of truth -->
  <section class="sb-card" aria-labelledby="sb-files-title">
    <header class="sb-head">
      <span class="sb-title">
        <strong id="sb-files-title">Attach from Drive</strong>
        <span>Shared › Q3 Planning · 8 files</span>
      </span>
      <p class="sb-hint">Drag straight down the rows to pick several <i>·</i> <kbd class="mv-kbd">Space</kbd> toggles</p>
    </header>

    <mv-snowball id="sb-files" modifier="none" items=".sb-file" label="Files to attach">
      <ul class="sb-files" aria-labelledby="sb-files-title">
        <li class="sb-file" data-value="q3-roadmap" data-size="2.4"><label><input type="checkbox" class="mv-checkbox" checked><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-danger)"><path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4M9 13h6M9 17h4"/></svg><span class="sb-name">Q3 roadmap.pdf</span><span class="sb-meta">2.4 MB</span><span class="sb-date">Sep 18, 2026</span></label></li>
        <li class="sb-file" data-value="budget" data-size="0.8"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-success)"><path d="M4 4h16v16H4zM4 10h16M4 15h16M10 4v16"/></svg><span class="sb-name">Budget forecast FY27.xlsx</span><span class="sb-meta">812 KB</span><span class="sb-date">Sep 16, 2026</span></label></li>
        <li class="sb-file" data-value="kickoff" data-size="14.6"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-warning)"><path d="M3 5h18v12H3zM8 21h8M12 17v4"/></svg><span class="sb-name">Kickoff deck, Lisbon offsite.pptx</span><span class="sb-meta">14.6 MB</span><span class="sb-date">Sep 12, 2026</span></label></li>
        <li class="sb-file" data-value="research" data-size="1.2"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-info)"><path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4M9 12h6M9 15h6M9 18h3"/></svg><span class="sb-name">User research synthesis.docx</span><span class="sb-meta">1.2 MB</span><span class="sb-date">Sep 9, 2026</span></label></li>
        <li class="sb-file" data-value="hiring" data-size="0.3"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-success)"><path d="M4 4h16v16H4zM4 10h16M4 15h16M10 4v16"/></svg><span class="sb-name">Hiring plan (Berlin &amp; Toronto).xlsx</span><span class="sb-meta">296 KB</span><span class="sb-date">Sep 5, 2026</span></label></li>
        <li class="sb-file" data-value="wireframes" data-size="8.9"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-accent)"><path d="M4 5h16v14H4z"/><path d="m4 16 5-5 4 4 2-2 5 5"/><circle cx="15.5" cy="9" r="1.5"/></svg><span class="sb-name">Checkout wireframes v4.png</span><span class="sb-meta">8.9 MB</span><span class="sb-date">Aug 29, 2026</span></label></li>
        <li class="sb-file" data-value="legal" data-size="0.5"><label><input type="checkbox" class="mv-checkbox" disabled><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-fg-subtle)"><path d="M6 11V8a6 6 0 0 1 12 0v3M5 11h14v10H5z"/></svg><span class="sb-name">Vendor contract (restricted).pdf</span><span class="sb-meta">488 KB</span><span class="sb-date">Aug 21, 2026</span></label></li>
        <li class="sb-file" data-value="retro" data-size="0.2"><label><input type="checkbox" class="mv-checkbox"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color:var(--mv-info)"><path d="M14 3H6v18h12V7z"/><path d="M14 3v4h4M9 12h6M9 15h6M9 18h3"/></svg><span class="sb-name">Sprint 38 retro notes.docx</span><span class="sb-meta">174 KB</span><span class="sb-date">Aug 14, 2026</span></label></li>
      </ul>
      <button slot="actions" type="button" class="mv-button" data-size="sm" data-snowball-action="attach" id="sb-attach">Attach 1 file</button>
    </mv-snowball>
    <p class="sb-files-foot" id="sb-files-foot"></p>
  </section>

  <script type="module">
    const photos = document.getElementById("sb-photos");
    const files = document.getElementById("sb-files");
    const grid = photos.querySelector(".sb-grid");
    const log = document.getElementById("sb-log");
    const foot = document.getElementById("sb-files-foot");
    const attach = document.getElementById("sb-attach");

    // Stylized landscape "photos", generated so the demo needs no network.
    const SCENES = [
      ["#f6c28b", "#fbe6cc", "#fff4dc", "#7d8fb0", "#51658a", "#2b3a55"],
      ["#c9d3df", "#eef2f6", "#ffffff", "#98a3b2", "#5a626e", "#1f2227"],
      ["#9fd3e6", "#e7f6fb", "#ffffff", "#cde8f2", "#7fb8cf", "#2f6b85"],
      ["#0b1a2e", "#1d5a52", "#c8f7df", "#143545", "#0d2533", "#050f18"],
      ["#d8d0c2", "#f2ede3", "#ffffff", "#8c8172", "#5a5046", "#2e2823"],
      ["#7fa7c9", "#d6e6f2", "#fff8e7", "#5f86a3", "#3d6280", "#203d55"],
      ["#b7d7cf", "#eaf5f1", "#ffffff", "#80bfb1", "#4f9486", "#28574d"],
      ["#98a7b6", "#dfe5ea", "#f4f6f8", "#6c7885", "#3e4751", "#1b2026"],
      ["#f09a66", "#fbd9a7", "#fff0c2", "#b0607a", "#7a3b5f", "#3d1f3a"],
      ["#a9c6b5", "#e3efe6", "#ffffff", "#6f9a7f", "#446b55", "#22392c"],
      ["#c5b3d8", "#efe6f5", "#fff6ea", "#9b86b3", "#6c5a88", "#3a2f52"],
      ["#86b6d6", "#dcecf5", "#ffffff", "#5b8f6c", "#34624a", "#17331f"],
      ["#2f3570", "#df8a6d", "#ffd3a3", "#5a4a7a", "#3a2f5a", "#1c1733"],
      ["#5fb0d6", "#c9ecf7", "#eafaff", "#3e8fb8", "#23698f", "#0f3f5c"],
      ["#e8c07a", "#f6e7c4", "#fffbe9", "#b98b52", "#7c5a36", "#3f2e1d"],
      ["#4b3b47", "#c9826a", "#ffb070", "#3b2f36", "#241c22", "#120d10"],
      ["#a8c3a0", "#e6efe1", "#ffffff", "#7aa06b", "#4f7545", "#2a4526"],
      ["#d6e5ef", "#f5f9fc", "#ffffff", "#b5d3e4", "#86b1ca", "#4a7d9c"],
    ];
    const scene = ([s1, s2, sun, m1, m2, m3], seed) => {
      let s = seed * 7919 + 17;
      const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
      const ridge = (base, amp) => {
        let d = `M0 300L0 ${base}`;
        for (let x = 0; x <= 400; x += 40) d += `L${x} ${(base - rnd() * amp).toFixed(0)}`;
        return `${d}L400 300Z`;
      };
      const sx = 60 + rnd() * 280;
      const sy = 60 + rnd() * 60;
      return `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300' preserveAspectRatio='xMidYMid slice'><defs><linearGradient id='s' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='${s1}'/><stop offset='1' stop-color='${s2}'/></linearGradient><radialGradient id='g'><stop offset='0' stop-color='${sun}' stop-opacity='.85'/><stop offset='1' stop-color='${sun}' stop-opacity='0'/></radialGradient></defs><rect width='400' height='300' fill='url(#s)'/><circle cx='${sx}' cy='${sy}' r='90' fill='url(#g)'/><circle cx='${sx}' cy='${sy}' r='22' fill='${sun}'/><path d='${ridge(190, 70)}' fill='${m1}'/><path d='${ridge(225, 50)}' fill='${m2}'/><path d='${ridge(262, 30)}' fill='${m3}'/></svg>`;
    };
    grid.querySelectorAll("img").forEach((img, i) => {
      img.src = `data:image/svg+xml,${encodeURIComponent(scene(SCENES[i % SCENES.length], i + 3))}`;
    });
    const pristine = [...grid.children].map((li) => li.cloneNode(true));
    const initial = photos.value.length ? photos.value : ["IMG_4102", "IMG_4106", "IMG_4112"];

    const mb = (items) => items.reduce((sum, el) => sum + Number(el.dataset.size || 0), 0).toFixed(1);
    const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
    const status = () => { const sel = photos.selected; log.textContent = `${plural(sel.length, "photo")} selected · ${mb(sel)} MB`; };

    photos.addEventListener("mv-select-change", (e) => {
      const { added, removed, source } = e.detail;
      const delta = [added.length && `+${added.length}`, removed.length && `−${removed.length}`].filter(Boolean).join(" ");
      status();
      if (source === "sweep" && delta) log.textContent = `Sweep ${delta} · ${log.textContent}`;
    });
    photos.addEventListener("mv-sweep-update", status); // live, while the sweep is still rolling
    photos.addEventListener("mv-sweep-end", (e) => { if (e.detail.canceled) log.textContent = `Sweep canceled · ${plural(photos.selected.length, "photo")} selected`; });
    photos.addEventListener("mv-action", (e) => {
      const { action, selected } = e.detail;
      const what = `${plural(selected.length, "photo")} (${mb(selected)} MB)`;
      if (action === "download") log.textContent = `Preparing a zip of ${what}…`;
      if (action === "album") { log.textContent = `Added ${plural(selected.length, "photo")} to “Best of Iceland”.`; photos.clear(); }
      if (action === "delete") { selected.forEach((li) => li.remove()); log.textContent = `Moved ${what} to Recently deleted.`; }
    });
    document.getElementById("sb-mode").addEventListener("mv-change", (e) => { photos.mode = e.detail.value; });

    // Replay: a scripted Shift + drag through the grid, with real pointer events.
    let playing = false;
    const play = async () => {
      if (playing || photos.sweeping) return;
      playing = true;
      if (grid.children.length !== pristine.length) grid.replaceChildren(...pristine.map((li) => li.cloneNode(true)));
      photos.value = initial;
      status();
      await new Promise(requestAnimationFrame);
      const items = [...grid.children];
      const cols = Math.max(1, Math.round(grid.clientWidth / (items[0].offsetWidth + 8)));
      const at = (i) => { const r = items[Math.min(i, items.length - 1)].getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };
      const path = [at(cols), at(cols + Math.min(3, cols - 1)), at(Math.min(4, cols - 1)), at(Math.min(5, cols - 1))];
      const base = { bubbles: true, cancelable: true, composed: true, pointerId: 1, pointerType: "mouse", isPrimary: true, shiftKey: true };
      const [x0, y0] = path[0];
      items[cols].dispatchEvent(new PointerEvent("pointerdown", { ...base, clientX: x0, clientY: y0, button: 0, buttons: 1 }));
      const legs = path.slice(1).map((p, i) => [path[i], p]);
      for (const [[ax, ay], [bx, by]] of legs) {
        const steps = 22;
        for (let s = 1; s <= steps; s++) {
          await new Promise(requestAnimationFrame);
          const k = s / steps;
          const x = ax + (bx - ax) * k;
          const y = ay + (by - ay) * k + Math.sin(k * Math.PI) * 10;
          (document.elementFromPoint(x, y) || document).dispatchEvent(new PointerEvent("pointermove", { ...base, clientX: x, clientY: y, button: -1, buttons: 1 }));
        }
      }
      await new Promise((r) => setTimeout(r, 180));
      const [xe, ye] = path.at(-1);
      window.dispatchEvent(new PointerEvent("pointerup", { ...base, clientX: xe, clientY: ye, button: 0, buttons: 0 }));
      playing = false;
    };
    document.getElementById("sb-play").addEventListener("click", play);
    const io = new IntersectionObserver(([entry]) => {
      if (!entry.isIntersecting) return;
      io.disconnect();
      setTimeout(play, 450);
    }, { threshold: 0.6 });
    io.observe(grid);

    // File picker: the tray's action reflects the selection.
    const sync = () => {
      const sel = files.selected;
      attach.textContent = sel.length ? `Attach ${plural(sel.length, "file")} · ${mb(sel)} MB` : "Attach";
    };
    files.addEventListener("mv-select-change", sync);
    files.addEventListener("mv-sweep-update", sync); // live, while the sweep is still rolling
    customElements.whenDefined("mv-snowball").then(sync);
    files.addEventListener("mv-action", (e) => {
      foot.textContent = `${plural(e.detail.selected.length, "file")} attached to your message.`;
      files.clear();
      sync();
    });
  </script>
</div>

Référence culturelle

Katamari Damacy, Keita Takahashi (Namco) (2004, jeu). Vous faites rouler une boule collante à travers le monde, tout ce qu’elle touche s’y colle, et plus elle grossit, plus les objets qu’elle peut ramasser sont gros. Dans l’interface, faire rouler le pointeur sur une grille colle chaque élément touché à une boule de sélection qui grossit et dont la portée augmente avec elle, puis dépose tout le butin dans un plateau d’actions groupées.

API

Attributes

NameTypeDefaultDescription
itemsCSS selector[data-value]Which descendants are selectable. Items get data-snowball-item and data-selected (styling hooks). Give them role="option" (in a listbox), "gridcell" or "row" so aria-selected is meaningful, or put an <input type="checkbox"> inside: then the checkbox is the state and the focus target. Rescanned automatically when the DOM changes.
modifiershift | alt | ctrl | meta | mod | noneshiftKey to hold for a mouse or pen sweep (mod = Cmd on Apple, Ctrl elsewhere). With the key held, a press anywhere in the collection starts a sweep and a click without movement toggles the item. "none" makes any drag that starts on an item a sweep while plain clicks pass through (pure picker surfaces). Touch always uses long-press.
drop-modifieralt | shift | ctrl | meta | mod | nonealtKey that inverts the sweep while held: rolling over items drops them from the selection. Can be pressed or released mid-sweep. Ignored when equal to modifier.
modecollect | paintcollectcollect: a sweep always adds (drop-modifier removes). paint: the first item decides, so starting on a selected item deselects everything rolled over. This is the natural way to drop items on touch, where there is no modifier key.
holdnumber (ms)450Long-press delay before a touch turns into a sweep. Moving more than 8px before that is treated as a scroll.
reachnumber (px)12Extra pick-up radius around the pointer when the ball is at full size. It grows with the selection from 0, so a bigger ball catches items it only brushes. 0 disables it.
scroll-edgenumber (px)56Size of the edge zones of the nearest scroll container (or the viewport) where a sweep auto-scrolls, faster near the edge. Items that scroll under a still pointer are collected too. 0 disables auto-scroll.
trayauto | always | noneautoThe sticky selection tray: shown while something is selected (auto), always, or never (bring your own toolbar and listen to mv-select-change).
keyboardon | offonon: roving tabindex over the items (or their checkboxes) with arrow keys (2D in grids, based on real geometry), Home/End, Shift + arrows roll, Space toggles, Ctrl/Cmd+A selects all, Escape clears. off: the app manages focus. Escape still cancels a sweep.
labelstringSelectionAccessible name of the tray region.
data-sweeping / data-ready / data-has-selectionset by the componentOn the host: data-sweeping="add | remove" during a sweep, data-ready while the modifier is held over it (cursor hint), data-has-selection when at least one item is selected. On items: data-snowball-stuck="add | remove" for items changed by the current sweep.

Properties

NameTypeDescription
selectedElement[]Selected items in document order (read-only).
valuestring[]data-value (else id) of the selected items. Setting it selects exactly those items (can be set before the element is defined). Does not emit mv-select-change.
sweepingbooleanTrue while a sweep is in progress (read-only).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement: label, count ({n}), selectAll, clear, hintPointer ({modifier}, {drop}), hintFree, hintTouch, hintKeys, announceAdded / announceRemoved / announceBoth ({added}, {removed}, {n}), announceNone, announceCancel, announceCount, announceAll, announceCleared. English defaults.
items / modifier / dropModifier / mode / hold / reach / scrollEdge / tray / keyboard / labelreflectedMirror the attributes.

Methods

NameDescription
select(target, on = true)Selects (or deselects with false) an element, a value or a list of them. Returns the items that changed. Programmatic changes do not emit mv-select-change.
toggle(target)Toggles one item (element or value); returns its new state, or null if it is not an item.
selectAll() / clear()Selects every enabled item, or deselects everything. Silent (no event).
refresh()Rescans items and re-measures them after a layout change the component cannot observe (e.g. a transform animation on an ancestor).

Events

NameDescription
mv-sweep-startCancelable, before a sweep begins. detail: { source: "mouse" | "pen" | "touch" | "keyboard", mode: "add" | "remove", origin (item or null) }. preventDefault() vetoes the sweep (e.g. while a bulk operation is running).
mv-select-changeThe selection changed through the user: once per sweep (net result, not per item), per toggle, per checkbox change, per tray or keyboard Select all / Clear. detail: { selected, values, added, removed, source: "sweep" | "toggle" | "checkbox" | "keyboard" | "tray" }.
mv-sweep-updateLive, during a sweep: one item just stuck to the ball or peeled off (not committed yet: Escape can still cancel). detail: { item, selected, count, mode }. For live labels, sounds or haptics; persist on mv-select-change instead.
mv-sweep-endA sweep finished. detail: { source, added, removed, canceled, selected, values }. A canceled sweep (Escape) has restored the previous selection and emits no mv-select-change.
mv-actionA tray action with data-snowball-action was clicked. detail: { action, selected, values }.

Content structure

NameDescription
(content)The collection: any markup containing the items (listbox, grid, ul, table…).
actionsChildren with slot="actions" are moved into the tray between the count and Select all / Clear. Give them data-snowball-action="<name>" to receive mv-action with the current selection, or handle their clicks yourself and read .selected.

CSS classes

NameDescription
mv-snowball-layerTop-layer overlay (popover="manual", aria-hidden, pointer-events: none) holding the ball while it is visible. data-mode="add | remove", data-reduced, data-source.
mv-snowball-ball / -core / -orbit / mv-snowball-count / mv-snowball-signThe ball: an anchor that trails the pointer (above the finger on touch), the shaded sphere (scaled by the selection size) and the container of stuck thumbnails. The count badge rides on one shoulder and becomes the whole chip under reduced motion; the minus sign on the other shoulder shows while the sweep drops items.
mv-snowball-thumb / mv-snowball-ghost / mv-snowball-letterThumbnails on the ball, taken from data-snowball-thumb, the item's first <img>, its first <svg>, or else its initial letter. data-back when on the far side of the ball; -ghost while flying in or out.
mv-snowball-trayThe sticky tray region: -count (with -ball, the count badge), -actions (slotted buttons), -end (-button Select all, -button[data-icon] Clear).

CSS variables

NameDefaultDescription
--mv-snowball-colorvar(--mv-accent)Ball, count badge and selected outline color.
--mv-snowball-drop-colorvar(--mv-fg-subtle)Ball color while dropping items (drop-modifier or paint from a selected item).
--mv-snowball-outline2px solid var(--mv-snowball-color)Default outline of selected items; set to none when your items style [data-selected] or [aria-selected="true"] themselves.
--mv-snowball-outline-offset-2pxOffset of that outline (negative draws it over the item's content).
--mv-snowball-thumb-size1.375remSize of the thumbnails stuck on the ball.
--mv-snowball-tray-offsetvar(--mv-space-4)Distance between the sticky tray and the bottom of the viewport or scroll container.

Accessibility

Selection stays in the platform's own semantics: items are options, gridcells or rows with aria-selected (set explicitly to true or false, and aria-multiselectable is added to a listbox or grid container), or they contain a real checkbox whose checked state, input and change events stay the source of truth. The sweep is never pointer-only. With the keyboard, a roving tabindex puts one item (or its checkbox) in the tab order. Arrow keys move by real geometry (Left/Right in reading order and RTL-aware, Up/Down to the nearest item of the next row, nothing sideways in a vertical list), Home/End jump. Shift + arrows rolls the same ball through the items, going back over the path deselects what it had just picked up, and releasing Shift ends the sweep. Space toggles one item, Ctrl/Cmd+A selects all, and Escape cancels a sweep in progress or clears the selection. A visually hidden hint, linked to the collection with aria-describedby, explains the modifier, long-press and keys. Nothing is announced per item: a single polite status message at the end of a sweep (“8 added. 12 selected.”, “Sweep canceled. 4 selected.”), a debounced count after single toggles, and messages for Select all and Clear. The ball, its thumbnails and flying copies live in an aria-hidden, pointer-events: none top layer and are purely decorative. The count is always also in the tray, a labelled region whose Clear button has an accessible name and keeps focus safe (focus returns to the active item when the tray hides). The click that ends a pointer sweep is swallowed so links and item handlers do not fire by accident. Touch: long-press (no callout or context menu) then drag, with auto-scroll at the edges, so selection never needs precise taps. Reduced motion (OS setting or data-motion="reduce"): no rolling ball, no orbit, no flying thumbnails or tugs, just a counter chip beside the pointer. Forced colors: Highlight / HighlightText for the ball, the count badge and the selected outline.

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