Event pacing
A drag fires a value change on every pointer move, and a search on every keystroke sends requests that are stale before they return. Chameleon's text, search, email, telephone, and number fields, its sliders and dials, and its navigation bar can hold back the events they fire, so your code hears a change only as often as it can use one. Adaptive pacing goes further: it measures how fast your server answers and never asks faster.
The modes
Each component that paces an event takes one of these modes. The value updates on every change; the mode decides only when the component tells your code.
| Mode | When your code hears | Use it for |
|---|---|---|
immediate |
On every change | Work that costs nothing, such as updating a label |
debounced |
Once the user pauses for the delay | A search that should wait for the user to stop typing |
throttled |
At most once per delay while changes continue | A live preview at a steady rate |
interaction-ended |
Once, when the drag or scroll ends | Saving the user's final choice |
adaptive |
As often as your async work keeps up | A live preview or search against a server |
Fields have no interaction-ended, because typing has no gesture with an end, and a field already fires change when the user commits a value. A field given interaction-ended, or any other value it doesn't know, uses its default, immediate.
Which components pace which event
| Component | Event paced | Mode property | Delay property and default | Default mode |
|---|---|---|---|---|
| Text, search, email, telephone, and number fields | input |
input-event-mode |
input-rate-limit-delay |
immediate |
| Slider, range slider, dial, and continuous dial | change |
change-mode |
rate-limit-delay |
interaction-ended |
| Navigation bar, for its scroll progress | scroll-progress-change |
scroll-change-mode |
scroll-rate-limit-delay |
throttled |
Each component paces only the event named in the table. A field's value changes on every keystroke, a slider or dial fires input on every movement, and the navigation bar's scrollProgress follows every frame. Text areas and password fields don't pace their events.
Pacing has no app-wide setting: set the mode on each component that needs one.
Adaptive pacing
A fixed delay is a guess about your server. Guess low and a slow server falls behind with a queue of requests; guess high and a fast one leaves the user looking at an old result. Adaptive pacing replaces the guess with a measurement.
Give the component your async work as asyncSink
- Makes one call at a time. It never starts a call while the last is still running, so requests can't pile up behind a slow server.
- Works on the newest value. Changes made during a call wait for it to finish and then go in one follow-up, which reads the value as it is when it starts.
- Measures each call. The component asks a fast server at most once per delay, so the delay sets the fastest rate rather than a fixed period. It never asks a slow server faster than the server answers, and it asks less often as a server slows under load.
- Backs off after a failure. When the promise rejects or doesn't settle in time, the wait before the next call grows, and it shrinks again as calls succeed.
- Gives up on a call after 30 seconds. One call that never finishes can't stop the component for good.
sink-duty-cyclerate-limit-delay
On a field
An adaptive field waits for the user to pause, so the first letter of a word never starts a search on its own. It waits for the longest of three things:
- The field's
input-rate-limit-delay - Slightly more than this user's usual gap between keystrokes, so a slow typist isn't interrupted mid-word
- The time until your async work is ready for its next call
The typing and async-work waits never exceed two seconds, and the field always waits at least input-rate-limit-delayasyncSink
In every mode, not only adaptive, a field calls its asyncSinkinput, one call at a time, and sink-duty-cycleinput events that arrive while a call runs share one follow-up call.
On a slider, a dial, or the navigation bar
The first change of a drag reaches your code at once, unless the last call is still running or its wait hasn't ended. After that, each change waits for the last call to finish, then for the pause the component sets from how long calls take. Without an asyncSinkthrottled does: once per rate-limit-delay
The navigation bar takes its async work as scrollAsyncSinkscroll-sink-duty-cycle
Async work in each framework
In each framework, asyncSink
Web components
const field = document.querySelector("cw-search-field");
field.inputEventMode = "adaptive";
field.asyncSink = async ({ abortSignal }) => {
const response = await fetch(`/api/recipes?q=${encodeURIComponent(field.value)}`, { signal: abortSignal });
showResults(await response.json());
};
abortSignal aborts when the component gives up on the call: after 30 seconds, or when the component leaves the page. Pass it to fetch so the request stops too. A newer value never interrupts a running call; it waits and goes in the next one. React components receive abortSignal as web components do. Blazor's ValueChangeRequest doesn't carry it.
React
import { useRef } from "react";
import { CrSlider } from "@materia-technologies-inc/chameleon-components-react";
export function Brightness() {
const slider = useRef(null);
return (
<CrSlider
ref={slider}
ariaLabel="Brightness"
changeMode="adaptive"
rateLimitDelay={50}
asyncSink={async () => {
await fetch(`/api/lamp?level=${slider.current.value}`, { method: "PUT" });
}}
/>
);
}
Blazor
<CbSlider @bind-Value="level" aria-label="Brightness"
ChangeMode="ChangeEventMode.Adaptive" RateLimitDelay="50"
AsyncSink="SendLevelAsync" />
@code {
double? level = 50;
Task SendLevelAsync(ValueChangeRequest request) => Lamp.SetLevelAsync(level);
}