Dial

Dial demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.

App.tsx

import './App.css';

import { Arc } from './Arc';
import { Scale } from './Scale';

export default () => (
  <div className="container">
    <h1>Custom Control</h1>
    <p>
      Drag the arc, or focus it and use the arrow keys. The number lives on the
      form above it, so the arc, the slider and the input are three views of one
      field - and none of them knows the others exist.
    </p>
    <Manuscript />
    <small>
      Arc finds the Scale above it and declares <code>children</code> on{' '}
      <code>render</code>, so the caller decides what sits in the well.
    </small>
  </div>
);

class Manuscript extends Scale {
  value = 14;
  max = 100;

  render() {
    const { value } = this;

    return (
      <form className="volume" onSubmit={(e) => e.preventDefault()}>
        <Arc>{roman(value)}</Arc>

        <div className="row">
          <Slider />
          <Digits />
        </div>

        <footer>
          <small>Manuscript, Volume {roman(value)}</small>
        </footer>
      </form>
    );
  }
}

const Slider = () => {
  const { is: scale, value, min, max } = Scale.get();

  return (
    <input
      type="range"
      min={min}
      max={max}
      value={value}
      onChange={(e) => scale.to(e.target.valueAsNumber)}
    />
  );
};

const Digits = () => {
  const { is: scale, value, min, max } = Scale.get();

  return (
    <input
      type="number"
      min={min}
      max={max}
      value={value}
      onChange={(e) => scale.to(e.target.valueAsNumber)}
    />
  );
};

const NUMERALS = [
  [100, 'C'],
  [90, 'XC'],
  [50, 'L'],
  [40, 'XL'],
  [10, 'X'],
  [9, 'IX'],
  [5, 'V'],
  [4, 'IV'],
  [1, 'I']
] as const;

// Declared, not an arrow: read by Manuscript above.
function roman(value: number) {
  let out = '';

  for (const [size, glyph] of NUMERALS)
    while (value >= size) {
      out += glyph;
      value -= size;
    }

  return out;
}

App.css

.volume {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s4);
  width: 100%;
  max-width: 22rem;
}

.volume .row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  width: 100%;
}

.volume input[type='range'] {
  padding: 0;
  border: none;
  background: none;
  accent-color: var(--accent);
  cursor: pointer;
}

.volume input[type='number'] {
  flex: none;
  width: 5rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* The arc and its readout share one box, so the well can sit inside the
   sweep without measuring anything. */
.arc {
  position: relative;
  width: 100%;
}

.arc svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
  cursor: grab;
  /* Claim the gesture from the browser, or a touch drag scrolls the page,
     selects the text it passes over, and flashes a tap highlight. */
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}

.arc svg:active {
  cursor: grabbing;
}

.arc path {
  fill: none;
  stroke-width: 14;
  stroke-linecap: round;
}

.arc .groove {
  stroke: var(--surface-2);
}

.arc .filled {
  stroke: var(--accent);
}

.arc .knob {
  fill: var(--bg);
  stroke: var(--accent);
  stroke-width: 4;
}

/* Pressing the arc focuses it, which must not read as a boxed-in widget. The
   knob carries the keyboard affordance instead. */
.arc svg:focus {
  outline: none;
}

.arc svg:focus-visible .knob {
  fill: var(--accent-soft);
  r: 13;
}

/* Anchored to the pivot rather than the box, since the lower half is empty. */
.well {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 17%;
  font-size: var(--t-2xl);
  font-weight: 700;
  pointer-events: none;
}

.well em {
  font-style: normal;
  letter-spacing: 0.05em;
}

Arc.tsx

import { Component, get, ref } from '@expressive/react';
import type { KeyboardEvent, PointerEvent, ReactNode } from 'react';

import { Scale } from './Scale';

const W = 240;
const H = 148;
const CX = 120;
const CY = 124;
const R = 100;

const TRACK = `M ${CX - R} ${CY} A ${R} ${R} 0 0 1 ${CX + R} ${CY}`;
const SWEEP = Math.PI * R;

const STEP: Record<string, number> = {
  ArrowLeft: -1,
  ArrowDown: -1,
  ArrowRight: 1,
  ArrowUp: 1
};

export class Arc extends Component {
  scale = get(Scale);

  track = ref<SVGSVGElement>();

  get progress() {
    const { value, min, max } = this.scale;

    return (value - min) / (max - min);
  }

  grab(e: PointerEvent<SVGSVGElement>) {
    e.currentTarget.setPointerCapture(e.pointerId);
    this.aim(e);
  }

  drag(e: PointerEvent<SVGSVGElement>) {
    if (e.currentTarget.hasPointerCapture(e.pointerId)) this.aim(e);
  }

  nudge(e: KeyboardEvent<SVGSVGElement>) {
    const by = STEP[e.key];

    if (!by) return;

    e.preventDefault();
    this.scale.to(this.scale.value + by);
  }

  aim(e: PointerEvent<SVGSVGElement>) {
    const { min, max } = this.scale;
    const box = this.track.current!.getBoundingClientRect();
    const unit = box.width / W;
    const x = e.clientX - (box.left + CX * unit);
    const y = box.top + CY * unit - e.clientY;

    // Clamping y at the pivot pins an off-arc drag to whichever end is nearer,
    // instead of letting it jump across.
    const turn = 1 - Math.atan2(Math.max(y, 0), x) / Math.PI;

    this.scale.to(min + turn * (max - min));
  }

  render(props = {} as { children?: ReactNode }) {
    const { progress } = this;
    const { value, min, max } = this.scale;
    const angle = Math.PI * (1 - progress);

    return (
      <div className="arc">
        <svg
          ref={this.track}
          viewBox={`0 0 ${W} ${H}`}
          role="slider"
          tabIndex={0}
          aria-valuenow={value}
          aria-valuemin={min}
          aria-valuemax={max}
          onPointerDown={this.grab}
          onPointerMove={this.drag}
          onKeyDown={this.nudge}>
          <path className="groove" d={TRACK} />
          <path className="filled" d={TRACK} strokeDasharray={`${progress * SWEEP} ${SWEEP}`} />
          <circle
            className="knob"
            cx={CX + R * Math.cos(angle)}
            cy={CY - R * Math.sin(angle)}
            r={11}
          />
        </svg>
        <div className="well">{props.children ?? value}</div>
      </div>
    );
  }
}

Scale.tsx

import { Component } from '@expressive/react';

export class Scale extends Component {
  value = 1;
  min = 1;
  max = 100;

  // Clamp and round on the way in, so no control has to.
  to(value: number) {
    if (Number.isNaN(value)) return;

    this.value = Math.min(this.max, Math.max(this.min, Math.round(value)));
  }
}