Custom Control
Custom Control 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>
<p>
Arc is a whole concern of its own - pointer capture, keyboard stepping, the
geometry that turns an angle into a value - and none of it leaks. It reads
the Scale above it, writes through <code>to()</code>, and owns its element
via <code>ref</code>. Nothing about dragging appears in the form.
</p>
<Manuscript />
<small>
In React that logic has nowhere to live but hooks the parent must arrange -
state, refs and handlers hoisted into whoever renders the control. Here it
is a class: instantiated by being rendered, and reusable by being placed
under any Scale. Arc also declares <code>children</code> on{' '}
<code>render</code>, so the caller still decides what sits in the well.
</small>
</div>
);
class Manuscript extends Scale {
value = 14;
max = 100;
get volume() {
return roman(this.value);
}
render() {
const { volume } = this;
return (
<form className="volume" onSubmit={(e) => e.preventDefault()}>
<Arc>{volume}</Arc>
<div className="row">
<Slider />
<Digits />
</div>
<footer>
<small>Manuscript, Volume {volume}</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)));
}
}