DiscreteControl Primitive

Complete API reference for DiscreteControl - the primitive for building custom discrete/enum value controls.

DiscreteControl is the primitive component for building custom discrete/enum value controls. It handles option management, value resolution, interaction, and layout while you define the visual appearance.

API Reference

NameTypeDefaultRequiredDescription
viewControlComponent<P>YesThe visualization component that implements ControlComponentView
viewPropsPNoProps specific to the view component
valuestring | numberNoCurrent value (controlled mode)
defaultValuestring | numberNoDefault value (uncontrolled mode)
onChange(event: AudioControlEvent<string | number>) => voidNoHandler for value changes
optionsArray<{ value: string | number; label?: string; midiValue?: number }>NoOption definitions for parameter model (ad-hoc mode)
childrenReact.ReactNodeNoOptionView children for visual content (ad-hoc or hybrid mode)
parameterDiscreteParameterNoAudio Parameter definition (overrides ad-hoc props)
labelstringNoLabel displayed below the component
viewBoxWidthUnitsnumberNoOverride viewBox width from view component
viewBoxHeightUnitsnumberNoOverride viewBox height from view component
labelHeightUnitsnumberNoOverride label height from view component
displayMode"scaleToFit" | "fill"scaleToFitNoHow the component scales within its container
labelMode"visible" | "hidden" | "none"visibleNoLabel display mode
labelPosition"above" | "below"belowNoLabel position relative to control
labelAlign"start" | "center" | "end"centerNoLabel horizontal alignment
classNamestringNoAdditional CSS classes
styleReact.CSSPropertiesNoAdditional inline styles

Options vs Children: Critical Distinction

Understanding the difference between options and children is essential:

  • options prop: Defines the parameter model (value, label, midiValue). This is the data definition.
  • children (OptionView): Provides visual content (ReactNodes) for rendering. This is the visual representation.

These serve different purposes and can be used together.

Modes of Operation

DiscreteControl supports four modes:

Mode 1: Ad-Hoc with Options Prop

Model from options, visual from children (if provided) or default rendering:

AdHocOptions.tsx
import { DiscreteControl, OptionView } from "@cutoff/audio-ui-react";
import { useState } from "react";
 
export default function AdHocOptionsExample() {
  const [value, setValue] = useState("sine");
 
  return (
    <DiscreteControl
      view={MyView}
      value={value}
      onChange={(e) => setValue(e.value)}
      options={[
        { value: "sine", label: "Sine" },
        { value: "square", label: "Square" },
        { value: "sawtooth", label: "Saw" },
      ]}
    >
      {/* Optional: Custom visual content */}
      <OptionView value="sine">Sine</OptionView>
      <OptionView value="square">Square</OptionView>
      <OptionView value="sawtooth">Saw</OptionView>
    </DiscreteControl>
  );
}

Mode 2: Ad-Hoc with Children Only

Model inferred from OptionView children, visual from children:

AdHocChildren.tsx
import { DiscreteControl, OptionView } from "@cutoff/audio-ui-react";
import { useState } from "react";
 
export default function AdHocChildrenExample() {
  const [value, setValue] = useState("sine");
 
  return (
    <DiscreteControl
      view={MyView}
      value={value}
      onChange={(e) => setValue(e.value)}
    >
      <OptionView value="sine">Sine</OptionView>
      <OptionView value="square">Square</OptionView>
      <OptionView value="sawtooth">Saw</OptionView>
    </DiscreteControl>
  );
}

Mode 3: Strict Mode (Parameter Only)

Model from parameter, visual via renderOption callback (not supported in current API - use Mode 4 instead):

StrictMode.tsx
import { createDiscreteParameter } from "@cutoff/audio-ui-react";
import { useState } from "react";
 
const waveformParam = createDiscreteParameter({
  paramId: "waveform",
  label: "Waveform",
  options: [
    { value: "sine", label: "Sine" },
    { value: "square", label: "Square" },
    { value: "sawtooth", label: "Saw" },
  ],
  defaultValue: "sine",
});
 
export default function StrictModeExample() {
  const [value, setValue] = useState("sine");
 
  return (
    <DiscreteControl
      view={MyView}
      parameter={waveformParam}
      value={value}
      onChange={(e) => setValue(e.value)}
    />
  );
}

Mode 4: Hybrid Mode (Parameter + Children)

Model from parameter, visual from children (matched by value):

HybridMode.tsx
import { createDiscreteParameter, OptionView } from "@cutoff/audio-ui-react";
import { useState } from "react";
 
const waveformParam = createDiscreteParameter({
  paramId: "waveform",
  label: "Waveform",
  options: [
    { value: "sine", label: "Sine" },
    { value: "square", label: "Square" },
    { value: "sawtooth", label: "Saw" },
  ],
  defaultValue: "sine",
});
 
export default function HybridModeExample() {
  const [value, setValue] = useState("sine");
 
  return (
    <DiscreteControl
      view={MyView}
      parameter={waveformParam}
      value={value}
      onChange={(e) => setValue(e.value)}
    >
      {/* Visual content matched by value */}
      <OptionView value="sine">
        <SineIcon />
      </OptionView>
      <OptionView value="square">
        <SquareIcon />
      </OptionView>
      <OptionView value="sawtooth">
        <SawIcon />
      </OptionView>
    </DiscreteControl>
  );
}

View Component Contract

Your view component must implement the ControlComponentView interface:

interface ControlComponentView {
  viewBox: { width: number; height: number };
  labelHeightUnits?: number; // Default: 20
  interaction?: {
    mode: "drag" | "wheel" | "both";
    direction: "vertical" | "horizontal" | "circular" | "both";
  };
}

View Props

Your view component receives these props:

type ControlComponentViewProps<P = Record<string, unknown>> = {
  normalizedValue: number; // 0.0 to 1.0 (maps to current option index)
  children?: React.ReactNode; // Visual content for current option
  className?: string;
  style?: React.CSSProperties;
  // ... any custom props from viewProps
} & P;

Important: normalizedValue represents the current option index normalized to 0-1, not the option value itself. Use it to position indicators or highlight the current selection.

Basic Usage

BasicUsage.tsx
import { DiscreteControl, OptionView, ControlComponentViewProps } from "@cutoff/audio-ui-react";
import { useState } from "react";
 
function SimpleSelectorView({ normalizedValue, children }: ControlComponentViewProps) {
  const positions = 4;
  const currentPosition = Math.round(normalizedValue * (positions - 1));
  const angle = (currentPosition / positions) * 360;
 
  return (
    <g>
      <circle cx="50" cy="50" r="45" fill="none" stroke="#ccc" strokeWidth="4" />
      <line
        x1="50"
        y1="50"
        x2={50 + 35 * Math.cos((angle * Math.PI) / 180)}
        y2={50 + 35 * Math.sin((angle * Math.PI) / 180)}
        stroke="currentColor"
        strokeWidth="4"
      />
    </g>
  );
}
 
SimpleSelectorView.viewBox = { width: 100, height: 100 };
 
export default function BasicUsageExample() {
  const [value, setValue] = useState("option1");
 
  return (
    <DiscreteControl
      view={SimpleSelectorView}
      value={value}
      onChange={(e) => setValue(e.value)}
      label="Selector"
    >
      <OptionView value="option1">1</OptionView>
      <OptionView value="option2">2</OptionView>
      <OptionView value="option3">3</OptionView>
      <OptionView value="option4">4</OptionView>
    </DiscreteControl>
  );
}

Parameter Integration

Ad-Hoc Mode

Provide options directly:

AdHocParameter.tsx
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
  options={[
    { value: "sine", label: "Sine" },
    { value: "square", label: "Square" },
    { value: "sawtooth", label: "Saw" },
  ]}
/>

Parameter Model Mode

Provide a full DiscreteParameter:

ParameterModel.tsx
import { createDiscreteParameter } from "@cutoff/audio-ui-react";
 
const waveformParam = createDiscreteParameter({
  paramId: "waveform",
  label: "Waveform",
  options: [
    { value: "sine", label: "Sine" },
    { value: "square", label: "Square" },
    { value: "sawtooth", label: "Saw" },
  ],
  defaultValue: "sine",
});
 
<DiscreteControl
  view={MyView}
  parameter={waveformParam}
  value={value}
  onChange={(e) => setValue(e.value)}
/>

Visual Content with Children

Use OptionView children to provide custom visual content:

VisualContent.tsx
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
>
  <OptionView value="sine">
    <SineIcon className="w-6 h-6" />
  </OptionView>
  <OptionView value="square">
    <SquareIcon className="w-6 h-6" />
  </OptionView>
  <OptionView value="sawtooth">
    <SawIcon className="w-6 h-6" />
  </OptionView>
</DiscreteControl>

The view component receives the current option's visual content via the children prop.

Interaction

DiscreteControl supports:

  • Click: Cycles to next value (wraps to first when reaching end)
  • Space/Enter: Cycles to next value (same as click)
  • Arrow Keys: Step increment/decrement (clamped at min/max, no wrap)
Interaction.tsx
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
  // Interaction is automatic - no configuration needed
/>

Value Resolution

When the current value doesn't match any option, DiscreteControl automatically finds the nearest valid option:

ValueResolution.tsx
// If value is "invalid", DiscreteControl finds nearest match
<DiscreteControl
  view={MyView}
  value="invalid" // Will resolve to nearest option
  onChange={(e) => setValue(e.value)}
  options={[
    { value: "sine", label: "Sine" },
    { value: "square", label: "Square" },
  ]}
/>

Custom View Props

Pass custom props to your view component:

CustomViewProps.tsx
function CustomView({
  normalizedValue,
  children,
  customColor,
}: ControlComponentViewProps<{ customColor: string }>) {
  return (
    <g>
      <circle
        cx="50"
        cy="50"
        r="45"
        fill="none"
        stroke={customColor}
        strokeWidth="4"
      />
      {/* Render current option's visual content */}
      <g transform="translate(50, 50)">{children}</g>
    </g>
  );
}
 
CustomView.viewBox = { width: 100, height: 100 };
 
<DiscreteControl
  view={CustomView}
  viewProps={{ customColor: "#3b82f6" }}
  value={value}
  onChange={(e) => setValue(e.value)}
>
  <OptionView value="sine">Sine</OptionView>
  <OptionView value="square">Square</OptionView>
</DiscreteControl>

Center Content (HTML Overlay)

Render HTML content as an overlay:

CenterContent.tsx
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
  htmlOverlay={
    <div style={{ fontSize: "20px", fontWeight: 700 }}>
      {currentOptionLabel}
    </div>
  }
/>

Or use the children prop pattern where children provide the visual content for the current option.

MIDI Integration

DiscreteControl supports MIDI mapping strategies:

Spread (Default)

Options distributed evenly across MIDI range:

MidiSpread.tsx
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
  options={[
    { value: "sine", label: "Sine" },
    { value: "square", label: "Square" },
    { value: "sawtooth", label: "Saw" },
  ]}
  midiMapping="spread"
  // Maps: 0 -> sine, 64 -> square, 127 -> sawtooth
/>

Sequential

MIDI value equals option index:

MidiSequential.tsx
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
  options={[
    { value: "sine", label: "Sine" },
    { value: "square", label: "Square" },
    { value: "sawtooth", label: "Saw" },
  ]}
  midiMapping="sequential"
  // Maps: 0 -> sine, 1 -> square, 2 -> sawtooth
/>

Custom

Use explicit midiValue in options:

MidiCustom.tsx
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
  options={[
    { value: "sine", label: "Sine", midiValue: 0 },
    { value: "square", label: "Square", midiValue: 42 },
    { value: "sawtooth", label: "Saw", midiValue: 84 },
  ]}
  midiMapping="custom"
/>

Performance Considerations

  • O(1) lookups: Value-to-index and option-by-value Maps for fast lookups
  • Memoization: Both DiscreteControl and view components are memoized
  • Value resolution: Automatic nearest-match resolution prevents invalid states
  • Re-renders: Only re-renders when value or options change

Advanced Usage Patterns

Data-Driven Options

When options come from external sources:

DataDriven.tsx
const waveforms = [
  { value: "sine", label: "Sine", icon: SineIcon },
  { value: "square", label: "Square", icon: SquareIcon },
  { value: "sawtooth", label: "Saw", icon: SawIcon },
];
 
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
  options={waveforms.map((w) => ({ value: w.value, label: w.label }))}
>
  {waveforms.map((w) => (
    <OptionView key={w.value} value={w.value}>
      <w.icon />
    </OptionView>
  ))}
</DiscreteControl>

Controlled vs Uncontrolled

ControlledUncontrolled.tsx
// Controlled (recommended)
const [value, setValue] = useState("sine");
<DiscreteControl
  view={MyView}
  value={value}
  onChange={(e) => setValue(e.value)}
>
  <OptionView value="sine">Sine</OptionView>
</DiscreteControl>
 
// Uncontrolled
<DiscreteControl
  view={MyView}
  defaultValue="sine"
  onChange={(e) => console.log(e.value)}
>
  <OptionView value="sine">Sine</OptionView>
</DiscreteControl>

Type Exports

import {
  DiscreteControl,
  DiscreteControlComponentProps,
  OptionView,
  ControlComponentView,
  ControlComponentViewProps,
} from "@cutoff/audio-ui-react";

Next Steps