DEV Community

WDSEGA
WDSEGA

Posted on

Component Deep Dive #39: Stepper — Long Press Acceleration and Decimal Precision

Stepper | Component Deep Dive #39: Stepper — Long Press Acceleration and Decimal Precision

Click to add 1, hold to auto-increment faster and faster. This interaction is more complex than it looks — timer management, precision handling, and boundary checking are the real challenges.

The Stepper (Number Input) is used in scenarios requiring precise numeric input: shopping quantities, rating settings, time interval configuration. Users can adjust values by clicking +/- buttons or typing directly in the input field.

Core Principle

Three key interactions:

  1. Single click — Click +/- button, value changes by one step
  2. Long press acceleration — Hold the button, value continuously increments with increasing speed
  3. Direct input — Type a value, validated on blur

The challenge is long press acceleration: you need a variable-interval timer. Initial interval ~300ms, decreasing by 20% per tick, minimum 30ms. Release clears the timer. Also handle precision — 0.1+0.2=0.30000000000000004.

Implementation

class Stepper {
  constructor(root, options = {}) {
    this.input = root.querySelector('.stepper-input');
    this.min = options.min ?? -Infinity;
    this.max = options.max ?? Infinity;
    this.step = options.step ?? 1;
    this.precision = this.calcPrecision(this.step);
    this.timer = null;
    this.interval = 300;
    this.minInterval = 30;
    this.acceleration = 0.8;
    this.bind();
  }

  calcPrecision(step) {
    const str = String(step);
    const dot = str.indexOf('.');
    return dot === -1 ? 0 : str.length - dot - 1;
  }

  startRepeat(action) {
    const delta = action === 'increase' ? this.step : -this.step;
    this.interval = 300;

    const tick = () => {
      this.adjust(delta);
      const val = this.getValue();
      if ((delta > 0 && val >= this.max) ||
          (delta < 0 && val <= this.min)) {
        this.stopRepeat();
        return;
      }
      this.interval = Math.max(this.minInterval,
        this.interval * this.acceleration);
      this.timer = setTimeout(tick, this.interval);
    };

    this.timer = setTimeout(tick, 400);
  }

  stopRepeat() {
    if (this.timer) { clearTimeout(this.timer); this.timer = null; }
  }

  setValue(value) {
    const clamped = Math.min(this.max, Math.max(this.min, value));
    const fixed = parseFloat(clamped.toFixed(this.precision));
    this.input.value = fixed;
  }
}
Enter fullscreen mode Exit fullscreen mode

Floating Point Precision

JavaScript uses IEEE 754 double-precision floats. 0.1+0.2 does not equal 0.3:

0.1 + 0.2  // 0.30000000000000004
0.3 - 0.1  // 0.19999999999999998
Enter fullscreen mode Exit fullscreen mode

Each adjustment accumulates error. The solution: round to the step's precision on every setValue:

const fixed = parseFloat(clamped.toFixed(this.precision));
Enter fullscreen mode Exit fullscreen mode

If step=0.01, precision=2, every setValue does toFixed(2) correction, ensuring the display is always clean two-decimal.

Long Press Acceleration Algorithm

Tick Interval (ms) Cumulative (ms)
1 300 300
2 240 540
3 192 732
5 123 1009
10 50 ~1350
15+ 30 ~1550

Initial delay 400ms (simulating "hold" feel), then start at 300ms, multiply by 0.8 each tick, minimum 30ms. After holding for 1.5 seconds, the user has incremented ~15 times.

Common Pitfalls

  1. Using setInterval instead of setTimeoutsetInterval has a fixed interval and cannot accelerate. Use recursive setTimeout with recalculated interval each time.

  2. Not preventDefault on touchstart — Long press on mobile triggers system menus or text selection. e.preventDefault() with { passive: false } prevents this.

  3. Forgetting boundary checks — Long press past max/min keeps sending meaningless adjust calls. Every tick must check bounds.

  4. Precision accumulation — Using value += step accumulates float errors. Fix with toFixed on every setValue, not just at the display layer.

Underlying Logic

A stepper is essentially a "discrete numeric controller": mapping continuous pointer interactions (clicks, long presses, drags) to discrete numeric changes. Single clicks are discrete mappings — one click, one step. Long press is continuous-to-discrete conversion — duration maps to step count, acceleration curve controls the mapping rate.

Precision handling is about bridging floating-point arithmetic and user expectations. Users expect 0.1+0.1=0.2, but hardware gives 0.30000000000000004. toFixed is a "translation layer" — translating the hardware's exact representation into the user's intuitive representation.


本文是「组件深度解析」系列第39篇。每篇拆解一个前端组件的核心原理与实现细节。

Top comments (0)