bêta

Wheel Picker <mv-wheel-picker>

Sélecteur à roue façon iOS : un tambour 3D (rotateX + perspective) qu’on lance d’un geste du doigt, avec de l’inertie puis un calage sur le cran le plus proche, ainsi que la molette de la souris, le clavier et la recherche par saisie. Plusieurs colonnes se combinent sous une bande commune pour les sélecteurs de date ou d’heure. Associé aux formulaires natifs.

CatégorieFormulaires
TypeWeb Component (<mv-wheel-picker>)
Statutbêta
Keywordswheel, picker, drum, ios, 3d, momentum, snap, date, time, listbox, form-associated

When to use

  • A mobile-first form picks a time, duration or date by flicking iOS-style drums
  • Several columns such as hours and minutes must be combined under one selection band
  • A short numeric range or list should be chosen with touch momentum and snapping

Avoid when

  • Desktop users pick a calendar date or range → use Date Picker instead
  • The list is long and users know what they want to type → use Combobox instead
  • A plain choice from a few options is enough → use Select instead

Installation

node scripts/add.mjs wheel-picker --out ./src/marvelous

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

Utilisation

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

<div id="wp-demo" style="display:flex;flex-wrap:wrap;gap:1.5rem;justify-content:center;align-items:stretch;width:100%;max-width:48rem">
  <form data-form="birth" style="flex:1 1 20rem;display:grid;gap:.875rem;justify-items:center;padding:1.25rem 1rem 1rem;border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface);box-shadow:var(--mv-shadow-sm)">
    <div style="justify-self:stretch;display:flex;justify-content:space-between;align-items:baseline;gap:1rem">
      <strong style="font-size:.9375rem">Date of birth</strong>
      <span data-out="birth" style="font-size:.8125rem;color:var(--mv-fg-muted)" aria-live="polite"></span>
    </div>
    <div class="mv-wheel-picker-group" data-date-group>
      <mv-wheel-picker name="month" label="Month" value="3" align="end" data-part="month" style="--mv-wheel-picker-font-size:1.125rem">
        <option value="1">January</option><option value="2">February</option><option value="3">March</option>
        <option value="4">April</option><option value="5">May</option><option value="6">June</option>
        <option value="7">July</option><option value="8">August</option><option value="9">September</option>
        <option value="10">October</option><option value="11">November</option><option value="12">December</option>
      </mv-wheel-picker>
      <mv-wheel-picker name="day" label="Day" min="1" max="31" value="14" data-part="day"></mv-wheel-picker>
      <mv-wheel-picker name="year" label="Year" min="1940" max="2026" value="1995" data-part="year"></mv-wheel-picker>
    </div>
    <p style="margin:0;font-size:.75rem;color:var(--mv-fg-subtle);text-align:center">Drag, flick, scroll, or use the arrows, Page ↑/↓ and type-ahead (“s” → September).</p>
  </form>

  <form data-form="alarm" style="flex:1 1 16rem;display:grid;gap:.875rem;justify-items:center;padding:1.25rem 1rem 1rem;border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface);box-shadow:var(--mv-shadow-sm)">
    <div style="justify-self:stretch;display:flex;justify-content:space-between;align-items:baseline;gap:1rem">
      <strong style="font-size:.9375rem">Alarm</strong>
      <span data-out="alarm" style="font-size:.8125rem;color:var(--mv-fg-muted)" aria-live="polite"></span>
    </div>
    <div class="mv-wheel-picker-group" style="--mv-wheel-picker-font-size:1.5rem;--mv-wheel-picker-item-height:2.5rem">
      <mv-wheel-picker name="hour" label="Hours" min="0" max="23" pad="2" loop value="7" unit="h"></mv-wheel-picker>
      <mv-wheel-picker name="minute" label="Minutes" min="0" max="55" step="5" pad="2" loop value="30" unit="min"></mv-wheel-picker>
    </div>
    <p style="margin:0;font-size:.75rem;color:var(--mv-fg-subtle);text-align:center">Infinitely looping columns, 5-minute steps.</p>
  </form>
</div>
<script type="module">
  const root = document.getElementById("wp-demo");
  const locale = "en-US";
  const parts = Object.fromEntries(["day", "month", "year"].map((p) => [p, root.querySelector(`[data-part="${p}"]`)]));
  const { day, month, year } = parts;
  // Column order follows the locale (en-US: Month / Day / Year; en-GB: Day / Month / Year).
  const order = new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" })
    .formatToParts(new Date(2000, 11, 31)).map((p) => p.type).filter((t) => t in parts);
  const group = root.querySelector("[data-date-group]");
  if ([...group.children].some((el, i) => el.dataset.part !== order[i])) group.append(...order.map((t) => parts[t]));
  const mi = order.indexOf("month");
  month.setAttribute("align", mi === 0 ? "end" : mi === order.length - 1 ? "start" : "center");
  const birthOut = root.querySelector('[data-out="birth"]');
  const fullDate = new Intl.DateTimeFormat(locale, { dateStyle: "full" });
  const renderBirth = () => {
    const d = Number(day.value), m = Number(month.value), y = Number(year.value);
    if (!d || !m || !y) return;
    birthOut.textContent = fullDate.format(new Date(y, m - 1, d));
  };
  // Month length follows month and year (Feb 29 in leap years).
  const syncDays = () => {
    const len = new Date(Number(year.value), Number(month.value), 0).getDate();
    if (day.options.length !== len) day.setAttribute("max", String(len));
    renderBirth();
  };
  for (const p of [month, year]) p.addEventListener("mv-change", syncDays);
  root.addEventListener("mv-input", (e) => { if (e.target.closest('[data-form="birth"]')) renderBirth(); });

  const alarm = root.querySelector('[data-form="alarm"]');
  const alarmOut = root.querySelector('[data-out="alarm"]');
  const renderAlarm = () => {
    const fd = new FormData(alarm);
    const h = Number(fd.get("hour")), m = Number(fd.get("minute"));
    const now = new Date();
    let diff = (h * 60 + m) - (now.getHours() * 60 + now.getMinutes());
    if (diff <= 0) diff += 1440;
    alarmOut.textContent = `Rings in ${Math.floor(diff / 60)}h ${String(diff % 60).padStart(2, "0")}m`;
  };
  alarm.addEventListener("mv-input", renderAlarm);
  alarm.addEventListener("mv-change", renderAlarm);
  customElements.whenDefined("mv-wheel-picker").then(() => requestAnimationFrame(() => { syncDays(); renderAlarm(); }));
  for (const f of root.querySelectorAll("form")) f.addEventListener("submit", (e) => e.preventDefault());
</script>

API

Attributes

NameTypeDefaultDescription
valuestringInitial value (option value). Restored on form reset.
namestringField name in the FormData.
labelstringAccessible name of the list (or aria-labelledby / <label for>).
optionsstring“a,b,c” list when there is neither a child <option> nor an options property.
min / max / stepnumber0 / 10 / 1Numeric range generated when no list is provided.
padnumberZero-pads numbers (pad="2" → 07).
loopbooleanInfinite drum (hours, minutes): always spins along the shortest path.
visiblenumber5Number of visible rows (height = rows × item height).
unitstringFixed unit to the right of the band (“h”, “min”).
alignstart | center | endcenterText alignment within the column.
disabled / requiredbooleanStandard states (disabled is also inherited from a <fieldset disabled>).

Properties

NameTypeDescription
valuestringSelected value; setting it spins the wheel.
optionsArray<string | { value, label, disabled }>Option list in JS (takes precedence). Disabled options are skipped.
selectedIndex / selectedOptionCurrent index and option.
form / labels / validityThrough ElementInternals.

Methods

NameDescription
focus()Focuses the wheel.

Events

NameDescription
mv-inputFires each time a new option passes under the band. detail: { value, label, index }.
mv-changeFires when the wheel settles on a different value.

Content structure

NameDescription
<option>Child options (value, label, disabled, selected).

CSS classes

NameDescription
mv-wheel-picker-groupContainer for several side-by-side wheels with a shared selection band.
mv-wheel-picker-viewport / -drum / -lens / -item / -band / -unitGenerated parts.

CSS variables

NameDefaultDescription
--mv-wheel-picker-item-height2.25remRow height.
--mv-wheel-picker-font-sizevar(--mv-text-xl)Text size.
--mv-wheel-picker-bandvar(--mv-bg-muted)Selection band background.
--mv-wheel-picker-mutedvar(--mv-fg-subtle)Color of rows outside the band.

Accessibility

The wheel is a focusable role="listbox" with aria-activedescendant pointing to the selected option (role="option" items are visually hidden but operable by screen readers); the drawn drum is aria-hidden. Keyboard: ↑/↓ one notch, Page ↑/↓ five, Home/End, type-ahead on first letters (accent-insensitive). Reduced motion: no momentum, the wheel jumps straight to the value.

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