Param Panel

Lifecycle & Configuration

The common API every control shares — atomic configure(), getConfig(), focus(), destroy(), and the base configuration options.

Beyond the reactive value API, every control — including buttons, folders, and monitors — shares a common lifecycle and configuration surface.

The common control interface

interface Control<C> {
  configure(config: Partial<C>): this;
  getConfig(): Readonly<C>;

  focus(): this;

  destroy(): void;
  isDestroyed(): boolean;

  /** Alias for destroy(), enabling `using` declarations. */
  [Symbol.dispose](): void;
}

configure()

configure() updates one or more options at once. Its defining property is that it is atomic: the whole proposed configuration is validated as a single operation before anything is applied.

threshold.configure({
  label: "Binary threshold",
  hint: "Pixels above this become white",
  max: 220,
  disabled: false,
});

Rules:

  • A missing property means "leave unchanged".
  • A present property set to undefined clears it, where that is supported.
  • The update is validated as one complete operation.
  • Rendering happens only after validation succeeds.
  • Value subscriptions fire only after successful application.
  • No partially applied state is ever visible.

Atomicity in practice

Consider swapping a slider's range and value together. If applied one field at a time, an intermediate state could be invalid (min above the old max). configure() avoids that entirely by validating the whole patch first:

// Valid as a whole — applied atomically.
threshold.configure({
  value: 0.5,
  min: 0,
  max: 1,
  step: 0.01,
});

If a patch is invalid, configure() rejects the entire change:

threshold.configure({
  min: 200,
  max: 100, // min > max
});

The result:

  • Throws a clear error.
  • Keeps the previous min, max, and value.
  • Notifies no subscribers.
  • Touches no DOM.

This is why you should prefer configure() over hypothetical per-field setters like setMin / setMax, which cannot be atomic.

getConfig()

const config = threshold.getConfig();

console.log(config.min);
console.log(config.max);

The returned object is a read-only snapshot — safe to inspect, and not a live reference to internal state. Mutating it does nothing.

focus()

threshold.focus();

Focuses the control's primary interactive element — the range/number input for a slider, the checkbox for a checkbox, the <select> for a select, the disclosure button for a folder, and so on. Returns the control for chaining.

destroy() and isDestroyed()

threshold.destroy();

Destroying a control releases everything it owns:

  • Its DOM, and its slot in the parent container
  • All subscribers
  • Event listeners and resize observers
  • Timers and animation frames
  • External store bindings

Destroying a folder recursively destroys all of its children — nested folders, monitors, subscriptions, and animation frames included.

After destruction, using the control throws a clear error rather than silently doing nothing:

threshold.get();
// ParamPanel: cannot use SliderControl after destroy()

Guard with isDestroyed() when a control's lifetime is uncertain:

if (!threshold.isDestroyed()) {
  threshold.set(100);
}

Any Unsubscribe function returned by subscribe() remains safe to call after the control is destroyed.

Automatic disposal with using

Every control implements [Symbol.dispose], so it works with TypeScript's using declarations (TS 5.2+). A control declared with using is destroyed automatically when it leaves scope — handy for short-lived controls in tests or scoped tools:

{
  using preview = pp.canvas({ label: "Preview", draw });
  preview.invalidate();
} // preview.destroy() runs here automatically

This is additive — explicit destroy() remains the primary API.

Base configuration

Every control config extends BaseControlConfig:

interface BaseControlConfig {
  /** Stable identifier for serialization, search, and diagnostics. */
  id?: string;

  /** User-facing label. */
  label: string;

  /** Optional explanatory text. */
  hint?: string;

  /** Whether user interaction is disabled. */
  disabled?: boolean;

  /** Whether the control is shown. */
  visible?: boolean;

  /** Additional CSS class. */
  className?: string;
}

Value controls additionally accept resetValue, the baseline used by reset():

interface ValueControlConfig<T> extends BaseControlConfig {
  /** Baseline restored by reset(). Defaults to the construction value. */
  resetValue?: T;
}

Labels and hints

control.configure({ label: "Edge threshold" });

control.configure({
  hint: "Minimum gradient magnitude retained as an edge",
});

A hint may render below the control, as a tooltip, or as an accessible description, depending on theme and density.

Disabled

control.configure({ disabled: true });

Disabled controls stay visible, cannot be changed by user input, may still be changed programmatically, and expose the correct accessibility attributes.

Visibility

control.configure({ visible: false });

Hidden controls remain part of the control tree — they keep their value, stay serializable, and take up no layout space. Continuous rendering pauses while a control is hidden.

Conditional controls

There is no implicit dependency tracking; wire conditions explicitly by subscribing:

mode.subscribe((value) => {
  advanced.configure({ visible: value === "advanced" });
});

enabled.subscribe((value) => {
  quality.configure({ disabled: !value });
});

Explicit subscriptions stay simple and predictable.

Error handling

Programmatic misuse — an invalid range, an unknown select option, using a destroyed control — throws a ParamPanelError. It carries a stable, machine- readable code so you can branch on the cause without parsing the message:

type ParamPanelErrorCode =
  | "invalid-range"
  | "invalid-step"
  | "invalid-value"
  | "duplicate-id"
  | "unknown-option"
  | "destroyed"
  | "invalid-state"
  | "missing-config";

class ParamPanelError extends Error {
  readonly code: ParamPanelErrorCode;
  readonly controlType?: string;
  readonly controlId?: string;
}
try {
  threshold.configure({ min: 200, max: 100 });
} catch (error) {
  if (error instanceof ParamPanelError && error.code === "invalid-range") {
    showToast(error.message);
  }
}

The message stays human-readable and actionable; the code is the stable contract. User input errors (typing an invalid number) are surfaced in the UI rather than thrown — only programmatic API misuse throws.

On this page