Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
# @suaveplan/react-control-surface - React 19 atomic-control surface renderer for @suaveplan/control-surface-schema. Foundation chunk: error codes, telemetry, skin contract, binding registry + handlers, grouping primitives, and the simple atomic controls (TextLabel, Image, LED, Switch2, Switch3, ButtonMomentary, ButtonLatching).
React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`.
This is the **foundation chunk** of the package — it lands the package scaffolding
(error codes, telemetry, skin contract, binding registry + handlers, grouping
primitives) and the seven simple atomic controls:
- `<TextLabel>` — decorative read-only text.
- `<Image>` — decorative image element.
- `<LED>` — boolean/0..1 brightness indicator.
- `<Switch2>` — two-state toggle (`role="switch"`).
- `<Switch3>` — three-state toggle.
- `<ButtonMomentary>` — press-and-hold trigger.
- `<ButtonLatching>` — sticky toggle.
The remaining controls (knob, sliders, displays, meters, XY pad, jog wheel, piano
key, drum pad) and the `<Surface>` runtime walker land in follow-up chunks.
<!-- begin:auto-docs -->
## Documentation tree
- [button-latching](./src/controls/button-latching/button-latching.md) — `<ButtonLatching>` is the sticky toggle: a click latches the button on, the next click latches it off. It is the counte…
- [button-latching](./src/controls/button-latching/button-latching.md) — `<ButtonLatching>` is the sticky toggle: a click latches the button on, the next click latches it off. It is the counte…
- [button-momentary](./src/controls/button-momentary/button-momentary.md) — `<ButtonMomentary>` is the press-and-hold trigger control. Pressing it emits `max` into `onChange`; releasing it emits…
- [button-momentary](./src/controls/button-momentary/button-momentary.md) — `<ButtonMomentary>` is the press-and-hold trigger control. Pressing it emits `max` into `onChange`; releasing it emits…
- [digital-display](./src/controls/digital-display/digital-display.md) — Read-only 7-segment / LCD-style readout for the control surface. The component is a typed information sink: it accepts…
- [digital-display](./src/controls/digital-display/digital-display.md) — Read-only 7-segment / LCD-style readout for the control surface. The component is a typed information sink: it accepts…
- [drum-pad](./src/controls/drum-pad/drum-pad.md) — Velocity-sensitive drum pad. Maps a pointer or keyboard press to a velocity 0..1 then through `[min, max]` so the same…
- [drum-pad](./src/controls/drum-pad/drum-pad.md) — Velocity-sensitive drum pad. Maps a pointer or keyboard press to a velocity 0..1 then through `[min, max]` so the same…
- [image](./src/controls/image/image.md) — `<Image>` is the decorative image control. Like `<TextLabel>`, it appears in the closed `ControlKind` enumeration so a…
- [image](./src/controls/image/image.md) — `<Image>` is the decorative image control. Like `<TextLabel>`, it appears in the closed `ControlKind` enumeration so a…
- [jog-wheel](./src/controls/jog-wheel/jog-wheel.md) — `<JogWheel>` is an endless rotary control inspired by DJ-deck jog wheels. Unlike a knob, it does not have an absolute v…
- [jog-wheel](./src/controls/jog-wheel/jog-wheel.md) — `<JogWheel>` is an endless rotary control inspired by DJ-deck jog wheels. Unlike a knob, it does not have an absolute v…
- [knob](./src/controls/knob/knob.md) — Rotary knob control — the cornerstone interaction primitive for the 2D control surface. Renders an SVG circle with a ro…
- [knob](./src/controls/knob/knob.md) — Rotary knob control — the cornerstone interaction primitive for the 2D control surface. Renders an SVG circle with a ro…
- [led](./src/controls/led/led.md) — `<LED>` is the output indicator control. It accepts a numeric value in the standard `BoundControlProps` domain and rend…
- [led](./src/controls/led/led.md) — `<LED>` is the output indicator control. It accepts a numeric value in the standard `BoundControlProps` domain and rend…
- [meter-peak](./src/controls/meter-peak/meter-peak.md) — `<MeterPeak>` is a digital-style peak meter that shows the instantaneous input level alongside a held-then-decaying pea…
- [meter-peak](./src/controls/meter-peak/meter-peak.md) — `<MeterPeak>` is a digital-style peak meter that shows the instantaneous input level alongside a held-then-decaying pea…
- [meter-spectral](./src/controls/meter-spectral/meter-spectral.md) — `<MeterSpectral>` renders a real-time bar graph of magnitude bins. Each bin is drawn as a vertical bar whose height is…
- [meter-spectral](./src/controls/meter-spectral/meter-spectral.md) — `<MeterSpectral>` renders a real-time bar graph of magnitude bins. Each bin is drawn as a vertical bar whose height is…
- [meter-vu](./src/controls/meter-vu/meter-vu.md) — `<MeterVU>` renders a single-channel level meter with the classic analogue VU response defined by IEC 60268-10: a 300 m…
- [meter-vu](./src/controls/meter-vu/meter-vu.md) — `<MeterVU>` renders a single-channel level meter with the classic analogue VU response defined by IEC 60268-10: a 300 m…
- [piano-key](./src/controls/piano-key/piano-key.md) — A single piano key. Two visual kinds (`white` / `black`) and three visual states (`idle` / `pressed` / `highlighted`).…
- [piano-key](./src/controls/piano-key/piano-key.md) — A single piano key. Two visual kinds (`white` / `black`) and three visual states (`idle` / `pressed` / `highlighted`).…
- [slider-h](./src/controls/slider-h/slider-h.md) — Horizontal slider control — the X-axis counterpart of `<SliderV>`. Every behavioural decision is delegated to the share…
- [slider-h](./src/controls/slider-h/slider-h.md) — Horizontal slider control — the X-axis counterpart of `<SliderV>`. Every behavioural decision is delegated to the share…
- [slider-v](./src/controls/slider-v/slider-v.md) — Vertical slider control. Pointer interaction sets the value directly from `clientY` against the rendered track rect — d…
- [slider-helpers](./src/controls/slider-v/slider-helpers.md) — Shared pure functions for both `<SliderV>` and `<SliderH>` so that value resolution, detent snapping, and keyboard step…
- [slider-v](./src/controls/slider-v/slider-v.md) — Vertical slider control. Pointer interaction sets the value directly from `clientY` against the rendered track rect — d…
- [switch-2](./src/controls/switch-2/switch-2.md) — `<Switch2>` is the two-position toggle control. It implements the WAI-ARIA "switch" pattern (`role="switch"` + `aria-ch…
- [switch-2](./src/controls/switch-2/switch-2.md) — `<Switch2>` is the two-position toggle control. It implements the WAI-ARIA "switch" pattern (`role="switch"` + `aria-ch…
- [switch-3](./src/controls/switch-3/switch-3.md) — `<Switch3>` is the three-position toggle, modelling the kind of "down-centre-up" or "low-mid-high" switch found on outb…
- [switch-3](./src/controls/switch-3/switch-3.md) — `<Switch3>` is the three-position toggle, modelling the kind of "down-centre-up" or "low-mid-high" switch found on outb…
- [text-label](./src/controls/text-label/text-label.md) — `<TextLabel>` is the decorative read-only control that renders a static text string. It is included in the `ControlKind…
- [text-label](./src/controls/text-label/text-label.md) — `<TextLabel>` is the decorative read-only control that renders a static text string. It is included in the `ControlKind…
- [xy-pad](./src/controls/xy-pad/xy-pad.md) — `<XYPad>` is an accessible 2-axis touch surface that emits normalised `{ x, y }` coordinates as the user drags a pointe…
- [xy-pad](./src/controls/xy-pad/xy-pad.md) — `<XYPad>` is an accessible 2-axis touch surface that emits normalised `{ x, y }` coordinates as the user drags a pointe…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module owns the stable, namespaced registry of every error code that `@suaveplan/react-control-surfac…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module owns the stable, namespaced registry of every error code that `@suaveplan/react-control-surfac…
- [group](./src/grouping/group/group.md) — `<Group>` is the layout primitive used for internal nodes of a control surface tree. It accepts a layout descriptor (CS…
- [group](./src/grouping/group/group.md) — `<Group>` is the layout primitive used for internal nodes of a control surface tree. It accepts a layout descriptor (CS…
- [panel](./src/grouping/panel/panel.md) — `<Panel>` is the labelled grouping primitive that wraps a group of controls in a titled box, optionally with collapse-t…
- [panel](./src/grouping/panel/panel.md) — `<Panel>` is the labelled grouping primitive that wraps a group of controls in a titled box, optionally with collapse-t…
- [rack](./src/grouping/rack/rack.md) — `<Rack>` is the grouping primitive that models a hardware-rack frame — a horizontal (default) or vertical sequence of f…
- [rack](./src/grouping/rack/rack.md) — `<Rack>` is the grouping primitive that models a hardware-rack frame — a horizontal (default) or vertical sequence of f…
- [runtime](./src/runtime/surface.md) — `<Surface>` is the runtime that turns a validated `ControlSurface` document into a live React DOM tree. It performs a d…
- [binding-registry](./src/runtime/binding-registry/binding-registry.md) — `BindingRegistry` is the discriminated dispatcher that links every bound control on a surface to the consumer-side logi…
- [compound](./src/runtime/binding-registry/handlers/compound.md) — The `compound` handler is the binding-registry slot responsible for fanning a single control's value out to multiple le…
- [dmx](./src/runtime/binding-registry/handlers/dmx.md) — The `dmx` handler is the binding-registry slot reserved for DMX universe / channel updates. The `@suaveplan/react-dmx`…
- [midi-in](./src/runtime/binding-registry/handlers/midi-in.md) — The `midiIn` handler is the binding-registry slot that hosts an inbound MIDI subscription — a control bound this way re…
- [midi-out](./src/runtime/binding-registry/handlers/midi-out.md) — The `midiOut` handler is the binding-registry slot that converts a control's value into an outbound MIDI Control Change…
- [plugin-param](./src/runtime/binding-registry/handlers/plugin-param.md) — The `pluginParam` handler is the binding-registry slot that dispatches a control's value to a plugin parameter via `use…
- [transport](./src/runtime/binding-registry/handlers/transport.md) — The `transport` handler is the binding-registry slot that dispatches a control's value to the audio transport — play/st…
- [binding-registry](./src/runtime/binding-registry/binding-registry.md) — `BindingRegistry` is the discriminated dispatcher that links every bound control on a surface to the consumer-side logi…
- [surface](./src/runtime/surface.md) — `<Surface>` is the runtime that turns a validated `ControlSurface` document into a live React DOM tree. It performs a d…
- [skin](./src/skin/surface-skin-provider.md) — `<SurfaceSkinProvider>` is the single React boundary that activates a skin for every atomic control inside its subtree.…
- [surface-skin-provider](./src/skin/surface-skin-provider.md) — `<SurfaceSkinProvider>` is the single React boundary that activates a skin for every atomic control inside its subtree.…
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module is the single owner of every OpenTelemetry instrument emitted by `@suaveplan/react-control-surfa…
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module is the single owner of every OpenTelemetry instrument emitted by `@suaveplan/react-control-surfa…
<!-- end:auto-docs -->
## Bindings
`BindingRegistry` discriminates on `Binding["kind"]` (defined by
`@suaveplan/control-surface-schema`) and dispatches to the registered handler.
Built-in handlers integrate with sibling packages:
- `pluginParam` — bridges to `useParameter` from `@suaveplan/react-audio`.
- `transport` — bridges to `useTransport` from `@suaveplan/react-audio`.
- `midiOut` — bridges to `useMidiOutput` from `@suaveplan/react-midi`.
- `midiIn` — bridges to `useMidiInput` from `@suaveplan/react-midi`.
- `dmx` — throws `REACT_CONTROL_SURFACE.BINDING_HANDLER_MISSING` until the
`@suaveplan/react-dmx` integration arrives in a later chunk.
- `compound` — fans out to every leaf in registration order.
Every handler is invoked through the same `BoundControlBinder` shape so the
runtime never special-cases binding kinds.
## Skin contract
`<SurfaceSkinProvider id="skin-id" vars={...}>` injects CSS variables on a
wrapping `<div>` — every atomic control reads its visual styling exclusively
through CSS variable resolution. JS-level skin dispatch is forbidden by the
spec: changing the skin is a pure CSS-variable swap.
## Telemetry
- `control_surface_interactions_total{kind}` — counter incremented on every
user-driven `onChange` from an atomic control.
- `control_surface_render_duration_ms` — histogram recorded by the runtime per
surface render commit (registered for future chunks).

View File

@@ -0,0 +1,145 @@
{
"name": "@suaveplan/react-control-surface",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "React 19 atomic-control surface renderer for @suaveplan/control-surface-schema. Foundation chunk: error codes, telemetry, skin contract, binding registry + handlers, grouping primitives, and the simple atomic controls (TextLabel, Image, LED, Switch2, Switch3, ButtonMomentary, ButtonLatching).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
},
"./skin": {
"import": {
"types": "./dist/skin/index.d.ts",
"default": "./dist/skin/index.js"
}
},
"./grouping": {
"import": {
"types": "./dist/grouping/index.d.ts",
"default": "./dist/grouping/index.js"
}
},
"./runtime/binding-registry": {
"import": {
"types": "./dist/runtime/binding-registry/index.d.ts",
"default": "./dist/runtime/binding-registry/index.js"
}
},
"./controls/text-label": {
"import": {
"types": "./dist/controls/text-label/text-label.d.ts",
"default": "./dist/controls/text-label/text-label.js"
}
},
"./controls/image": {
"import": {
"types": "./dist/controls/image/image.d.ts",
"default": "./dist/controls/image/image.js"
}
},
"./controls/led": {
"import": {
"types": "./dist/controls/led/led.d.ts",
"default": "./dist/controls/led/led.js"
}
},
"./controls/switch-2": {
"import": {
"types": "./dist/controls/switch-2/switch-2.d.ts",
"default": "./dist/controls/switch-2/switch-2.js"
}
},
"./controls/switch-3": {
"import": {
"types": "./dist/controls/switch-3/switch-3.d.ts",
"default": "./dist/controls/switch-3/switch-3.js"
}
},
"./controls/button-momentary": {
"import": {
"types": "./dist/controls/button-momentary/button-momentary.d.ts",
"default": "./dist/controls/button-momentary/button-momentary.js"
}
},
"./controls/button-latching": {
"import": {
"types": "./dist/controls/button-latching/button-latching.d.ts",
"default": "./dist/controls/button-latching/button-latching.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/control-surface-schema": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/react-audio": "workspace:^",
"@suaveplan/react-midi": "workspace:^",
"tslib": "catalog:build"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@testing-library/jest-dom": "catalog:testing",
"@testing-library/react": "catalog:testing",
"@testing-library/user-event": "catalog:testing",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,19 @@
/**
* Local mirror of the foundation chunk's `BoundControlProps` contract.
* This 5-line shape definition mirrors `src/contract/bound-control.types.ts`
* which is authored by the foundation agent. When the foundation merges,
* the mid-controls will re-point their imports to the canonical location
* and this file is deleted as part of the chunk-C fold-up. Mirrors foundation chunk.
*/
export interface BoundControlProps {
readonly value: number;
readonly onChange: (value: number) => void;
readonly min: number;
readonly max: number;
readonly step?: number;
readonly default?: number;
readonly disabled?: boolean;
readonly label?: string;
readonly ariaLabel?: string;
readonly renderHint?: "auto" | "2d";
}

View File

@@ -0,0 +1,41 @@
import type { RenderHint } from "@suaveplan/control-surface-schema";
/**
* Common props implemented by every atomic control component. Pure
* value-in / value-out: the component reads `value`, accepts user input,
* calls `onChange(next)`. No React context, no side-effects other than
* `onChange`.
*
* The numeric `value`/`min`/`max`/`step`/`default` triple is the universal
* domain even for inherently boolean / discrete controls — booleans map to
* `{ 0, 1 }`, three-position switches map to `{ 0, 0.5, 1 }`. This keeps
* the `BindingRegistry` agnostic of every control's value model.
*/
export interface BoundControlProps {
/** Current value of the control, expressed in the control's domain. */
readonly value: number;
/** Callback invoked with the next value after a user interaction. */
onChange(next: number): void;
/** Lower bound of the value domain (inclusive). */
readonly min: number;
/** Upper bound of the value domain (inclusive). */
readonly max: number;
/** Optional snap-step for keyboard increments. */
readonly step?: number;
/** Optional default value used by double-click-reset and equivalents. */
readonly default?: number;
/** When `true`, the control becomes read-only and visually subdued. */
readonly disabled?: boolean;
/** Visible label rendered next to the control. */
readonly label?: string;
/**
* Explicit `aria-label` override. When omitted, the label is reused as
* the aria-label.
*/
readonly ariaLabel?: string;
/**
* Renderer hint. Always `"2d"` for this package; `"auto"` defers to the
* runtime's renderer selection. Reserved for R3F-sibling parity.
*/
readonly renderHint?: RenderHint;
}

View File

@@ -0,0 +1 @@
export type { BoundControlProps } from "./bound-control.types.js";

View File

@@ -0,0 +1,69 @@
# `<ButtonLatching>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<ButtonLatching>` is the sticky toggle: a click latches the button on,
the next click latches it off. It is the counterpart to
`<ButtonMomentary>` and reuses the same `aria-pressed` pattern so users
who navigate by screen reader experience continuity across the two
variants — the only behavioural difference is when the value flips.
Unlike the momentary variant, the latched state is read entirely from
the schema's numeric value via a midpoint threshold. There is no local
state to track because the press has no second "release" event that
needs synchronisation with the visual chrome.
## Features
- Single-click toggle.
- Keyboard activation via Space and Enter with `event.repeat`
suppression.
- WAI-ARIA `aria-pressed` reflection of the latched state.
- Telemetry: `control_surface_interactions_total{kind="button-latching"}`
on every toggle.
- Pure `isButtonLatchingOn` helper for unit testing the threshold.
## Usage: Basic
```tsx
<ButtonLatching
value={looping ? 1 : 0}
onChange={(v) => setLooping(v === 1)}
min={0}
max={1}
label="Loop"
/>
```
## Usage: Advanced
```tsx
<ButtonLatching
value={state}
onChange={(v) => registry.dispatch(binding, v)}
min={0}
max={1}
label="Bypass"
ariaLabel="Bypass effect chain"
disabled={readOnly}
className="bypass"
/>
```
## API Reference
- `<ButtonLatching>` props: `BoundControlProps & { className?, style? }`.
- `isButtonLatchingOn(value, min, max)` — pure threshold helper.
## Implementation Notes
`event.repeat` is suppressed in the keyboard handler so an OS-level
auto-repeat does not flip the latch on every key event while the user
holds Space. The midpoint threshold is the same as `<Switch2>` — these
two controls differ only in their visual chrome; if a surface designer
prefers a chunky push-button rather than a thin toggle they can pick
the latching button with no behavioural change.

View File

@@ -0,0 +1,22 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { ButtonLatching } from "./button-latching.js";
const meta = {
title: "react-control-surface/controls/ButtonLatching",
component: ButtonLatching,
} satisfies Meta<typeof ButtonLatching>;
export default meta;
type Story = StoryObj<typeof meta>;
const base = { onChange: () => undefined, min: 0, max: 1 };
export const Default: Story = { args: { ...base, value: 0, label: "Loop" } };
export const On: Story = { args: { ...base, value: 1, label: "Loop" } };
export const Disabled: Story = {
args: { ...base, value: 0, label: "Loop", disabled: true },
};
export const Playground: Story = {
args: { ...base, value: 0, label: "Latch" },
};

View File

@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ButtonLatching, isButtonLatchingOn } from "./button-latching.js";
const base = { min: 0, max: 1 } as const;
afterEach(() => cleanup());
describe("<ButtonLatching>", () => {
it("click latches on then off", { timeout: 5000 }, () => {
const spy = vi.fn();
const { rerender } = render(
<ButtonLatching {...base} value={0} onChange={spy} label="Loop" />
);
const btn = screen.getByRole("button", { name: "Loop" });
expect(btn.getAttribute("aria-pressed")).toBe("false");
fireEvent.click(btn);
expect(spy).toHaveBeenLastCalledWith(1);
rerender(
<ButtonLatching {...base} value={1} onChange={spy} label="Loop" />
);
fireEvent.click(screen.getByRole("button", { name: "Loop" }));
expect(spy).toHaveBeenLastCalledWith(0);
});
it("responds to Space and Enter, ignores repeats and other keys", {
timeout: 5000,
}, () => {
const spy = vi.fn();
render(<ButtonLatching {...base} value={0} onChange={spy} label="X" />);
const btn = screen.getByRole("button", { name: "X" });
fireEvent.keyDown(btn, { key: " " });
fireEvent.keyDown(btn, { key: " ", repeat: true });
fireEvent.keyDown(btn, { key: "Enter" });
fireEvent.keyDown(btn, { key: "Tab" });
expect(spy).toHaveBeenCalledTimes(2);
});
it("respects disabled / aria-label / class / style fallback", {
timeout: 5000,
}, () => {
const spy = vi.fn();
render(
<ButtonLatching
{...base}
value={0}
onChange={spy}
disabled
ariaLabel="Override"
className="big"
style={{ padding: 1 }}
/>
);
const btn = screen.getByRole("button", { name: "Override" });
fireEvent.click(btn);
expect(spy).not.toHaveBeenCalled();
expect(btn.className).toBe("rcs-button-latching big");
expect(btn.style.padding).toBe("1px");
});
it("defaults aria-label to 'latch' when no label/ariaLabel", {
timeout: 5000,
}, () => {
render(
<ButtonLatching {...base} value={0} onChange={() => undefined} />
);
expect(screen.getByRole("button", { name: "latch" })).toBeDefined();
});
it("isButtonLatchingOn obeys midpoint threshold", { timeout: 5000 }, () => {
expect(isButtonLatchingOn(0.5, 0, 1)).toBe(true);
expect(isButtonLatchingOn(0.49, 0, 1)).toBe(false);
});
});

View File

@@ -0,0 +1,78 @@
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
} from "react";
import { recordInteraction } from "../../telemetry/telemetry.js";
import type { ButtonLatchingProps } from "./button-latching.types.js";
/** Maps the numeric value into the latched on/off state. */
export function isButtonLatchingOn(
value: number,
min: number,
max: number
): boolean {
return value >= (min + max) / 2;
}
/**
* Sticky toggle: clicking flips between `min` and `max`. Reuses the same
* `aria-pressed` pattern as `<ButtonMomentary>` so consumers can swap the
* two without losing screen-reader continuity, but a single click here
* leaves the button latched until the next click. Space and Enter
* activate the toggle from the keyboard.
*/
export function ButtonLatching(props: ButtonLatchingProps): ReactNode {
const {
value,
onChange,
min,
max,
label,
ariaLabel,
disabled,
className,
style,
} = props;
const on = isButtonLatchingOn(value, min, max);
const toggle = useCallback(() => {
if (disabled === true) return;
recordInteraction("button-latching");
onChange(on ? min : max);
}, [disabled, max, min, on, onChange]);
const onKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.repeat) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
toggle();
}
},
[toggle]
);
return (
<button
type="button"
aria-pressed={on}
aria-label={ariaLabel ?? label ?? "latch"}
aria-disabled={disabled === true ? true : undefined}
data-kind="button-latching"
data-pressed={on ? "true" : "false"}
className={
className === undefined
? "rcs-button-latching"
: `rcs-button-latching ${className}`
}
style={style}
onClick={toggle}
onKeyDown={onKeyDown}
>
{label === undefined ? null : (
<span aria-hidden="true">{label}</span>
)}
</button>
);
}

View File

@@ -0,0 +1,7 @@
import type { CSSProperties } from "react";
import type { BoundControlProps } from "../bound-control/bound-control.types.js";
export interface ButtonLatchingProps extends BoundControlProps {
readonly className?: string;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,5 @@
export {
ButtonLatching,
isButtonLatchingOn,
} from "./button-latching.js";
export type { ButtonLatchingProps } from "./button-latching.types.js";

View File

@@ -0,0 +1,74 @@
# `<ButtonMomentary>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<ButtonMomentary>` is the press-and-hold trigger control. Pressing it
emits `max` into `onChange`; releasing it emits `min`. The release also
fires when the pointer leaves the button while still pressed, so a user
who drags off the button mid-hold doesn't get stuck in the held state.
Keyboard interaction follows the same edge model: Space / Enter down
emit max, the corresponding up emits min, and `event.repeat` is
suppressed so OS auto-repeat doesn't generate a stream of duplicate
"down" events.
The pressed state is tracked locally in `useState` so the visual chrome
updates synchronously with the user input. The schema's numeric value is
informational; the rendered chrome follows the user's gesture rather
than waiting for a round-trip through the binding registry.
## Features
- Press-down / release edge dispatch.
- Pointer-leave releases stuck holds.
- Keyboard activation via Space and Enter with `event.repeat`
suppression.
- WAI-ARIA `aria-pressed` reflection.
- Telemetry: `control_surface_interactions_total{kind="button-momentary"}`
on every press.
- `disabled` blocks both pointer and keyboard activation.
## Usage: Basic
```tsx
<ButtonMomentary
value={triggered ? 1 : 0}
onChange={(v) => setTriggered(v === 1)}
min={0}
max={1}
label="Trigger"
/>
```
## Usage: Advanced
```tsx
<ButtonMomentary
value={state}
onChange={(v) => {
registry.dispatch(binding, v);
if (v === 1) sampler.trigger();
}}
min={0}
max={1}
label="One-shot"
ariaLabel="Trigger one-shot sample (Space to hold)"
/>
```
## API Reference
- `<ButtonMomentary>` props: `BoundControlProps & { className?, style? }`.
## Implementation Notes
The local `pressed` state guards against double-press without an
intervening release — a second pointer-down with no release is dropped.
This is essential under touch input where rapid taps can interleave. The
pointer-leave handler only fires release when the button is currently
pressed; a stray leave with no prior press is a no-op. Disabled state
short-circuits before the state mutation so a disabled component never
contributes to the press counter.

View File

@@ -0,0 +1,19 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { ButtonMomentary } from "./button-momentary.js";
const meta = {
title: "react-control-surface/controls/ButtonMomentary",
component: ButtonMomentary,
} satisfies Meta<typeof ButtonMomentary>;
export default meta;
type Story = StoryObj<typeof meta>;
const base = { value: 0, onChange: () => undefined, min: 0, max: 1 };
export const Default: Story = { args: { ...base, label: "Trigger" } };
export const Disabled: Story = {
args: { ...base, label: "Trigger", disabled: true },
};
export const Playground: Story = { args: { ...base, label: "Hold to play" } };

View File

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ButtonMomentary } from "./button-momentary.js";
const base = { value: 0, min: 0, max: 1 } as const;
afterEach(() => cleanup());
describe("<ButtonMomentary>", () => {
it("emits max on pointer down and min on pointer up", {
timeout: 5000,
}, () => {
const spy = vi.fn();
render(<ButtonMomentary {...base} onChange={spy} label="Trigger" />);
const btn = screen.getByRole("button", { name: "Trigger" });
fireEvent.pointerDown(btn);
expect(spy).toHaveBeenLastCalledWith(1);
expect(btn.getAttribute("aria-pressed")).toBe("true");
fireEvent.pointerUp(btn);
expect(spy).toHaveBeenLastCalledWith(0);
expect(btn.getAttribute("aria-pressed")).toBe("false");
});
it("releases when pointer leaves while pressed", { timeout: 5000 }, () => {
const spy = vi.fn();
render(<ButtonMomentary {...base} onChange={spy} label="X" />);
const btn = screen.getByRole("button", { name: "X" });
fireEvent.pointerDown(btn);
fireEvent.pointerLeave(btn);
expect(spy).toHaveBeenLastCalledWith(0);
});
it("ignores pointer leave when not pressed", { timeout: 5000 }, () => {
const spy = vi.fn();
render(<ButtonMomentary {...base} onChange={spy} label="X" />);
fireEvent.pointerLeave(screen.getByRole("button", { name: "X" }));
expect(spy).not.toHaveBeenCalled();
});
it("responds to Space and Enter with repeat-suppression", {
timeout: 5000,
}, () => {
const spy = vi.fn();
render(<ButtonMomentary {...base} onChange={spy} label="X" />);
const btn = screen.getByRole("button", { name: "X" });
fireEvent.keyDown(btn, { key: " " });
fireEvent.keyDown(btn, { key: " ", repeat: true });
fireEvent.keyUp(btn, { key: " " });
expect(spy).toHaveBeenCalledTimes(2);
spy.mockReset();
fireEvent.keyDown(btn, { key: "Enter" });
fireEvent.keyUp(btn, { key: "Enter" });
expect(spy).toHaveBeenCalledTimes(2);
fireEvent.keyDown(btn, { key: "Tab" });
fireEvent.keyUp(btn, { key: "Tab" });
expect(spy).toHaveBeenCalledTimes(2);
});
it("ignores double press without intervening release", {
timeout: 5000,
}, () => {
const spy = vi.fn();
render(<ButtonMomentary {...base} onChange={spy} label="X" />);
const btn = screen.getByRole("button", { name: "X" });
fireEvent.pointerDown(btn);
fireEvent.pointerDown(btn);
expect(spy).toHaveBeenCalledTimes(1);
fireEvent.pointerUp(btn);
fireEvent.pointerUp(btn);
expect(spy).toHaveBeenCalledTimes(2);
});
it("respects disabled and ariaLabel/className/style", {
timeout: 5000,
}, () => {
const spy = vi.fn();
render(
<ButtonMomentary
{...base}
onChange={spy}
disabled
ariaLabel="Override"
className="big"
style={{ padding: 1 }}
/>
);
const btn = screen.getByRole("button", { name: "Override" });
fireEvent.pointerDown(btn);
fireEvent.pointerUp(btn);
expect(spy).not.toHaveBeenCalled();
expect(btn.className).toBe("rcs-button-momentary big");
expect(btn.style.padding).toBe("1px");
});
it("falls back to default aria-label", { timeout: 5000 }, () => {
render(<ButtonMomentary {...base} onChange={() => undefined} />);
expect(screen.getByRole("button", { name: "momentary" })).toBeDefined();
});
});

View File

@@ -0,0 +1,100 @@
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useState,
} from "react";
import { recordInteraction } from "../../telemetry/telemetry.js";
import type { ButtonMomentaryProps } from "./button-momentary.types.js";
/**
* Press-and-hold trigger. Emits `max` on press-down (pointer / Space /
* Enter) and `min` on release. Tracks the pressed state locally so the
* visual chrome (`data-pressed`, `aria-pressed`) updates immediately even
* when the consumer is slow to round-trip the value.
*/
export function ButtonMomentary(props: ButtonMomentaryProps): ReactNode {
const { onChange, min, max, label, ariaLabel, disabled, className, style } =
props;
const [pressed, setPressed] = useState(false);
const press = useCallback(() => {
if (disabled === true || pressed) return;
setPressed(true);
recordInteraction("button-momentary");
onChange(max);
}, [disabled, max, onChange, pressed]);
const release = useCallback(() => {
if (disabled === true || !pressed) return;
setPressed(false);
onChange(min);
}, [disabled, min, onChange, pressed]);
const onPointerDown = useCallback(
(event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
press();
},
[press]
);
const onPointerUp = useCallback(() => {
release();
}, [release]);
const onPointerLeave = useCallback(() => {
if (pressed) {
release();
}
}, [pressed, release]);
const onKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.repeat) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
press();
}
},
[press]
);
const onKeyUp = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
release();
}
},
[release]
);
return (
<button
type="button"
aria-pressed={pressed}
aria-label={ariaLabel ?? label ?? "momentary"}
aria-disabled={disabled === true ? true : undefined}
data-kind="button-momentary"
data-pressed={pressed ? "true" : "false"}
disabled={disabled === true ? true : undefined}
className={
className === undefined
? "rcs-button-momentary"
: `rcs-button-momentary ${className}`
}
style={style}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{label === undefined ? null : (
<span aria-hidden="true">{label}</span>
)}
</button>
);
}

View File

@@ -0,0 +1,7 @@
import type { CSSProperties } from "react";
import type { BoundControlProps } from "../bound-control/bound-control.types.js";
export interface ButtonMomentaryProps extends BoundControlProps {
readonly className?: string;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,2 @@
export { ButtonMomentary } from "./button-momentary.js";
export type { ButtonMomentaryProps } from "./button-momentary.types.js";

View File

@@ -0,0 +1,110 @@
# `<DigitalDisplay>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Read-only 7-segment / LCD-style readout for the control surface. The
component is a typed information sink: it accepts a discriminated
`mode` (`numeric` | `text` | `scrolling`) and renders the appropriate
visualisation. Although `<DigitalDisplay>` implements the same
`BoundControlProps` contract as input controls (so it can drop into
the same schema slots), it never emits `onChange` — the display is
pure output.
## Features
- Three modes (`numeric` with precision + optional units, `text`,
`scrolling` with CSS-keyframe overflow scroll).
- Scrolling mode honours `prefers-reduced-motion` by disabling the
animation via a `@media` block injected alongside the keyframes.
- `role="status"` with `aria-live="polite"` so screen readers receive
numeric updates without taking focus.
- A hidden screen-reader-only span echoes the numeric / text content
outside the clipped visual area, so SR users get the full value
even when the visible LCD is too short.
- CSS-variable theming: `--cs-display-font`, `--cs-display-color`,
`--cs-display-bg`.
- NaN / Infinity render as `—` (em-dash).
## Usage: Basic
```tsx
import { DigitalDisplay } from "@suaveplan/react-control-surface";
export function TempoReadout({ bpm }: { bpm: number }) {
return (
<DigitalDisplay
mode={{ kind: "numeric", precision: 1, units: "BPM" }}
value={bpm}
min={20}
max={300}
onChange={() => undefined}
label="Tempo"
/>
);
}
```
## Usage: Advanced
A patch-name readout that scrolls when the patch name is longer than
the LCD window:
```tsx
import { DigitalDisplay } from "@suaveplan/react-control-surface";
export function PatchName({ name }: { name: string }) {
return (
<DigitalDisplay
mode={{
kind: "scrolling",
text: name,
windowChars: 8,
speedPxPerSec: 40,
}}
value={0}
min={0}
max={1}
onChange={() => undefined}
widthPx={160}
heightPx={28}
label="Patch Name"
/>
);
}
```
## API Reference
| Prop | Type | Default |
| ----------- | ----------------------------------------------------------------- | -------- |
| `mode` | `{kind: "numeric" \| "text" \| "scrolling", ...}` | required |
| `value` | `number` | required |
| `min`/`max` | `number` | required |
| `onChange` | `(v: number) => void` | required |
| `disabled` | `boolean` | `false` |
| `label` | `string` | — |
| `ariaLabel` | `string` | — |
| `widthPx` | `number` | `120` |
| `heightPx` | `number` | `32` |
| `className` | `string` | — |
`numeric` mode adds: `precision?: number` (default 0), `units?: string`.
`text` mode adds: `text: string`.
`scrolling` mode adds: `text: string`, `speedPxPerSec?: number`
(default 30), `windowChars?: number` (default 8).
## Implementation Notes
Scrolling mode injects an inline `<style>` block with a unique keyframe
name (`cs-display-scroll`); the keyframe duration is derived from
text length × per-glyph width / configured speed, so longer strings
scroll at a constant linear-px-per-second rate regardless of length.
The `@media (prefers-reduced-motion: reduce)` rule short-circuits the
animation to `none`, satisfying WAI-ARIA APG guidance for moving
content. The component intentionally does not use `requestAnimationFrame`
or React state ticks — CSS handles the animation so the React render
graph stays static.

View File

@@ -0,0 +1,82 @@
import { useEffect, useState } from "react";
import { DigitalDisplay } from "./digital-display.js";
const meta = {
title: "react-control-surface/DigitalDisplay",
component: DigitalDisplay,
};
export default meta;
export const Default = {
render: () => (
<DigitalDisplay
mode={{ kind: "numeric", precision: 1, units: "BPM" }}
value={120.5}
min={20}
max={300}
onChange={() => undefined}
label="Tempo"
/>
),
};
export const Playground = {
args: { precision: 2, units: "Hz", value: 440 },
render: (args: { precision: number; units: string; value: number }) => {
function Demo() {
const [v, setV] = useState(args.value);
useEffect(() => {
const id = setInterval(() => {
setV((x) => x + (Math.random() - 0.5) * 4);
}, 250);
return () => clearInterval(id);
}, []);
return (
<DigitalDisplay
mode={{
kind: "numeric",
precision: args.precision,
units: args.units,
}}
value={v}
min={0}
max={20000}
onChange={() => undefined}
label="Frequency"
/>
);
}
return <Demo />;
},
};
export const TextMode = {
render: () => (
<DigitalDisplay
mode={{ kind: "text", text: "READY" }}
value={0}
min={0}
max={1}
onChange={() => undefined}
label="Status"
/>
),
};
export const ScrollingMode = {
render: () => (
<DigitalDisplay
mode={{
kind: "scrolling",
text: "PATCH 042 — DREAMY PAD - INIT",
windowChars: 8,
speedPxPerSec: 40,
}}
value={0}
min={0}
max={1}
onChange={() => undefined}
label="Patch Name"
/>
),
};

View File

@@ -0,0 +1,255 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, render, screen } from "@testing-library/react";
import { DigitalDisplay } from "./digital-display.js";
afterEach(() => cleanup());
describe("<DigitalDisplay>", () => {
it("renders numeric value with default precision (0)", {
timeout: 5000,
}, () => {
render(
<DigitalDisplay
mode={{ kind: "numeric" }}
value={42.7}
min={0}
max={100}
onChange={() => undefined}
label="Tempo"
/>
);
const el = screen.getByRole("status");
expect(el.textContent).toContain("43");
expect(el.getAttribute("data-mode")).toBe("numeric");
});
it("renders numeric with precision and units", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{ kind: "numeric", precision: 2, units: "Hz" }}
value={440}
min={0}
max={20000}
onChange={() => undefined}
/>
);
const el = screen.getByRole("status");
expect(el.textContent).toContain("440.00 Hz");
});
it("renders em-dash for non-finite value", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{ kind: "numeric", precision: 1 }}
value={Number.NaN}
min={0}
max={1}
onChange={() => undefined}
/>
);
expect(screen.getByRole("status").textContent).toContain("—");
});
it("renders text mode", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{ kind: "text", text: "READY" }}
value={0}
min={0}
max={1}
onChange={() => undefined}
/>
);
const el = screen.getByRole("status");
expect(el.textContent).toContain("READY");
expect(el.getAttribute("data-mode")).toBe("text");
});
it("scrolling: non-overflowing text renders inert", {
timeout: 5000,
}, () => {
render(
<DigitalDisplay
mode={{ kind: "scrolling", text: "HI", windowChars: 8 }}
value={0}
min={0}
max={1}
onChange={() => undefined}
/>
);
const el = screen.getByRole("status");
expect(el.querySelector('[data-overflow="false"]')).not.toBeNull();
});
it("scrolling: overflowing text animates", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{
kind: "scrolling",
text: "THIS IS A LONG MESSAGE",
windowChars: 8,
speedPxPerSec: 50,
}}
value={0}
min={0}
max={1}
onChange={() => undefined}
/>
);
const el = screen.getByRole("status");
const scroller = el.querySelector(
'[data-cs-scroll="true"]'
) as HTMLElement;
expect(scroller).not.toBeNull();
expect(scroller.style.animation).toContain("cs-display-scroll");
});
it("scrolling: defaults are applied when speed/window omitted", {
timeout: 5000,
}, () => {
render(
<DigitalDisplay
mode={{ kind: "scrolling", text: "OVERFLOWING TEXT HERE" }}
value={0}
min={0}
max={1}
onChange={() => undefined}
/>
);
const scroller = screen
.getByRole("status")
.querySelector('[data-cs-scroll="true"]') as HTMLElement;
expect(scroller).not.toBeNull();
});
it("exposes aria-label from label", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{ kind: "text", text: "READY" }}
value={0}
min={0}
max={1}
label="Status"
onChange={() => undefined}
/>
);
expect(screen.getByRole("status").getAttribute("aria-label")).toBe(
"Status"
);
});
it("exposes aria-label from ariaLabel override", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{ kind: "text", text: "x" }}
value={0}
min={0}
max={1}
ariaLabel="Override"
label="Ignored"
onChange={() => undefined}
/>
);
expect(screen.getByRole("status").getAttribute("aria-label")).toBe(
"Override"
);
});
it("falls back to default aria-label", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{ kind: "text", text: "x" }}
value={0}
min={0}
max={1}
onChange={() => undefined}
/>
);
expect(screen.getByRole("status").getAttribute("aria-label")).toBe(
"Display"
);
});
it("reflects disabled state in aria", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{ kind: "text", text: "x" }}
value={0}
min={0}
max={1}
disabled
onChange={() => undefined}
/>
);
expect(screen.getByRole("status").getAttribute("aria-disabled")).toBe(
"true"
);
});
it("forwards className", { timeout: 5000 }, () => {
const { container } = render(
<DigitalDisplay
mode={{ kind: "text", text: "x" }}
value={0}
min={0}
max={1}
className="custom-cls"
onChange={() => undefined}
/>
);
expect(container.querySelector(".custom-cls")).not.toBeNull();
});
it("respects widthPx / heightPx", { timeout: 5000 }, () => {
render(
<DigitalDisplay
mode={{ kind: "text", text: "x" }}
value={0}
min={0}
max={1}
widthPx={200}
heightPx={40}
onChange={() => undefined}
/>
);
const el = screen.getByRole("status") as HTMLElement;
expect(el.style.width).toBe("200px");
expect(el.style.height).toBe("40px");
});
it("numeric: omitting units yields no trailing space", {
timeout: 5000,
}, () => {
const { container } = render(
<DigitalDisplay
mode={{ kind: "numeric", precision: 1 }}
value={1.234}
min={0}
max={10}
onChange={() => undefined}
/>
);
const numeric = container.querySelector(
'span[data-mode="numeric"]'
) as HTMLElement;
expect(numeric.textContent).toBe("1.2");
});
it("numeric: empty units string is treated as no units", {
timeout: 5000,
}, () => {
const { container } = render(
<DigitalDisplay
mode={{ kind: "numeric", precision: 0, units: "" }}
value={5}
min={0}
max={10}
onChange={() => undefined}
/>
);
const numeric = container.querySelector(
'span[data-mode="numeric"]'
) as HTMLElement;
expect(numeric.textContent).toBe("5");
});
});

View File

@@ -0,0 +1,135 @@
import type { ReactNode } from "react";
import type {
DigitalDisplayMode,
DigitalDisplayProps,
} from "./digital-display.types.js";
const DEFAULT_WIDTH_PX = 120;
const DEFAULT_HEIGHT_PX = 32;
const DEFAULT_SPEED = 30;
const DEFAULT_WINDOW = 8;
function formatNumeric(
value: number,
precision: number | undefined,
units: string | undefined
): string {
const p = precision ?? 0;
const body = Number.isFinite(value) ? value.toFixed(p) : "—";
return units !== undefined && units !== "" ? `${body} ${units}` : body;
}
function renderBody(
mode: DigitalDisplayMode,
value: number,
widthPx: number,
heightPx: number
): ReactNode {
if (mode.kind === "numeric") {
return (
<span data-mode="numeric" data-units={mode.units ?? ""}>
{formatNumeric(value, mode.precision, mode.units)}
</span>
);
}
if (mode.kind === "text") {
return <span data-mode="text">{mode.text}</span>;
}
const window = mode.windowChars ?? DEFAULT_WINDOW;
const speed = mode.speedPxPerSec ?? DEFAULT_SPEED;
const overflowing = mode.text.length > window;
if (!overflowing) {
return (
<span data-mode="scrolling" data-overflow="false">
{mode.text}
</span>
);
}
const pixelDistance = mode.text.length * (heightPx * 0.55);
const durationSec = pixelDistance / speed;
const animationName = "cs-display-scroll";
return (
<>
<style>{`
@keyframes ${animationName} {
from { transform: translateX(0); }
to { transform: translateX(-${pixelDistance}px); }
}
@media (prefers-reduced-motion: reduce) {
[data-cs-scroll="true"] { animation: none !important; }
}
`}</style>
<span
data-mode="scrolling"
data-overflow="true"
data-cs-scroll="true"
style={{
display: "inline-block",
whiteSpace: "nowrap",
animation: `${animationName} ${durationSec}s linear infinite`,
width: widthPx,
}}
>
{mode.text}
</span>
</>
);
}
/**
* Read-only 7-segment / LCD-style display. Three modes:
* `numeric` (renders `value.toFixed(precision)` with optional units),
* `text` (static string), `scrolling` (overflow → CSS keyframe scroll
* with `prefers-reduced-motion` honoured).
*/
export function DigitalDisplay(props: DigitalDisplayProps): ReactNode {
const {
mode,
value,
widthPx = DEFAULT_WIDTH_PX,
heightPx = DEFAULT_HEIGHT_PX,
label,
ariaLabel,
disabled,
className,
} = props;
const ariaContent =
mode.kind === "numeric"
? formatNumeric(value, mode.precision, mode.units)
: mode.text;
return (
<output
className={className}
aria-live="polite"
aria-label={ariaLabel ?? label ?? "Display"}
aria-disabled={disabled ? "true" : "false"}
data-mode={mode.kind}
data-cs-display="true"
style={{
display: "inline-block",
width: widthPx,
height: heightPx,
overflow: "hidden",
whiteSpace: "nowrap",
fontFamily:
"var(--cs-display-font, ui-monospace, SFMono-Regular, monospace)",
color: "var(--cs-display-color, #7fff7f)",
background: "var(--cs-display-bg, #0a0a0a)",
padding: "0 8px",
boxSizing: "border-box",
lineHeight: `${heightPx}px`,
textAlign: "right",
}}
>
{renderBody(mode, value, widthPx, heightPx)}
<span
data-cs-display-sr
style={{ position: "absolute", left: "-9999px" }}
>
{ariaContent}
</span>
</output>
);
}

View File

@@ -0,0 +1,32 @@
import type { BoundControlProps } from "../bound-control.types.js";
export type DigitalDisplayMode =
| {
readonly kind: "numeric";
readonly precision?: number;
readonly units?: string;
}
| { readonly kind: "text"; readonly text: string }
| {
readonly kind: "scrolling";
readonly text: string;
/** Pixels per second the text scrolls. Default: 30. */
readonly speedPxPerSec?: number;
/** Max visible character count before overflow scrolls. Default: 8. */
readonly windowChars?: number;
};
/**
* `<DigitalDisplay>` is a read-only output; it still implements
* `BoundControlProps` for type-uniformity with input controls — but
* `onChange` is never invoked. `value` is consumed by `numeric` mode,
* ignored by `text` / `scrolling`.
*/
export interface DigitalDisplayProps extends BoundControlProps {
readonly mode: DigitalDisplayMode;
/** Display width in pixels. Default: 120. */
readonly widthPx?: number;
/** Display height in pixels. Default: 32. */
readonly heightPx?: number;
readonly className?: string;
}

View File

@@ -0,0 +1,5 @@
export { DigitalDisplay } from "./digital-display.js";
export type {
DigitalDisplayMode,
DigitalDisplayProps,
} from "./digital-display.types.js";

View File

@@ -0,0 +1,132 @@
# `<DrumPad>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Velocity-sensitive drum pad. Maps a pointer or keyboard press to a
velocity 0..1 then through `[min, max]` so the same component can
serve as a 0..127 MIDI velocity emitter, a 0..1 normalized parameter
trigger, or anything in between. Velocity sourcing is layered:
hardware `PointerEvent.pressure` is preferred when the device supplies
a non-zero value (Apple Pencil, pressure-sensitive trackpads,
ChromeOS active styluses); otherwise a duration heuristic measures
press hold time and produces velocity via a caller-replaceable
function.
## Features
- Two velocity sources, transparently selected: hardware pressure or
press-duration heuristic.
- Live pressure tracking: `pointermove` updates velocity continuously
while pressure is supplied (useful for aftertouch-style modulation).
- Keyboard activation (Space, Enter) using the duration heuristic.
- Auto-repeat keyboard suppression — held keys don't re-arm the timer.
- Pointer capture for drift-tolerant release.
- Strike-then-release pattern: caller receives `(velocity)` followed
immediately by `min` so downstream consumers can treat each press
as a discrete event without bookkeeping.
- Injectable `nowMs` clock for test determinism.
- `role="button"` with live `aria-pressed`.
- CSS-variable theming (`--cs-pad-idle`, `--cs-pad-pressed`,
`--cs-pad-border`, `--cs-pad-label-color`).
## Usage: Basic
```tsx
import { useState } from "react";
import { DrumPad } from "@suaveplan/react-control-surface";
export function KickPad() {
const [v, setV] = useState(0);
return (
<DrumPad
value={v}
onChange={setV}
min={0}
max={127}
displayLabel="KICK"
/>
);
}
```
## Usage: Advanced
A 4×4 pad grid with a custom velocity curve and shared state:
```tsx
import { useState } from "react";
import { DrumPad } from "@suaveplan/react-control-surface";
const LABELS = [
"KICK","SNARE","CLAP","RIM",
"OH","CH","RIDE","CRASH",
"TOM1","TOM2","TOM3","PERC",
"FX1","FX2","FX3","FX4",
];
// Aggressive curve: 80 ms → max velocity.
const curve = (ms: number) => Math.min(1, ms / 80) ** 0.7;
export function Grid() {
const [values, setValues] = useState(() => LABELS.map(() => 0));
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 80px)",
gridGap: 6,
}}
>
{LABELS.map((lbl, i) => (
<DrumPad
key={lbl}
displayLabel={lbl}
value={values[i]}
onChange={(v) => {
const next = values.slice();
next[i] = v;
setValues(next);
}}
min={0}
max={127}
velocityFromDurationMs={curve}
/>
))}
</div>
);
}
```
## API Reference
| Prop | Type | Default |
| ------------------------ | ----------------------------- | ------------------------ |
| `value` | `number` | required |
| `onChange` | `(v: number) => void` | required |
| `min`/`max` | `number` | required |
| `disabled` | `boolean` | `false` |
| `label` | `string` | — |
| `ariaLabel` | `string` | — |
| `displayLabel` | `string` | — |
| `widthPx` | `number` | `80` |
| `heightPx` | `number` | `80` |
| `velocityFromDurationMs` | `(ms: number) => number` | `min(1, ms / 200)` |
| `nowMs` | `() => number` | `performance.now` |
| `className` | `string` | — |
## Implementation Notes
The strike + release pattern uses `queueMicrotask` so consumers see
two distinct emissions in order: first the velocity at strike, then
`min` immediately after. This keeps downstream parameter-bound state
trivially correct (no manual reset) while preserving the strike
event for envelope retriggering. Keyboard auto-repeat suppression
checks the active-press ref before re-arming so a held key produces
a single sustained press rather than a stream of strikes. The
injectable `nowMs` exists exclusively so tests can run with a fake
clock and produce deterministic velocity values without relying on
real wall-clock timing.

View File

@@ -0,0 +1,113 @@
import { useState } from "react";
import { DrumPad } from "./drum-pad.js";
const meta = {
title: "react-control-surface/DrumPad",
component: DrumPad,
};
export default meta;
export const Default = {
render: () => {
function Demo() {
const [v, setV] = useState(0);
return (
<DrumPad
value={v}
onChange={setV}
min={0}
max={127}
displayLabel="KICK"
/>
);
}
return <Demo />;
},
};
export const Playground = {
args: { widthPx: 120, heightPx: 120, max: 127 },
render: (args: { widthPx: number; heightPx: number; max: number }) => {
function Demo() {
const [v, setV] = useState(0);
return (
<DrumPad
value={v}
onChange={setV}
min={0}
max={args.max}
displayLabel="PAD"
widthPx={args.widthPx}
heightPx={args.heightPx}
/>
);
}
return <Demo />;
},
};
export const Grid4x4 = {
render: () => {
const labels = [
"KICK",
"SNARE",
"CLAP",
"RIM",
"OH",
"CH",
"RIDE",
"CRASH",
"TOM1",
"TOM2",
"TOM3",
"PERC",
"FX1",
"FX2",
"FX3",
"FX4",
];
function Demo() {
const [values, setValues] = useState<number[]>(() =>
labels.map(() => 0)
);
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 80px)",
gridGap: 6,
}}
>
{labels.map((lbl, i) => (
<DrumPad
key={lbl}
value={values[i]}
onChange={(v) => {
const next = values.slice();
next[i] = v;
setValues(next);
}}
min={0}
max={127}
displayLabel={lbl}
/>
))}
</div>
);
}
return <Demo />;
},
};
export const Disabled = {
render: () => (
<DrumPad
value={0}
onChange={() => undefined}
min={0}
max={127}
disabled
displayLabel="OFF"
/>
),
};

View File

@@ -0,0 +1,291 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { DrumPad } from "./drum-pad.js";
afterEach(() => cleanup());
function makeClock(start = 0) {
let t = start;
return {
now: () => t,
advance: (ms: number) => {
t += ms;
},
};
}
function setup(overrides: Partial<Parameters<typeof DrumPad>[0]> = {}) {
const onChange = vi.fn();
const utils = render(
<DrumPad
value={0}
min={0}
max={1}
onChange={onChange}
displayLabel="KICK"
{...overrides}
/>
);
const el = utils.container.querySelector("button") as HTMLButtonElement;
return { onChange, el, ...utils };
}
describe("<DrumPad>", () => {
it("renders as a button", { timeout: 5000 }, () => {
const { el } = setup();
expect(el.getAttribute("role")).toBe("button");
expect(el.getAttribute("aria-label")).toBe("KICK");
expect(el.getAttribute("aria-pressed")).toBe("false");
expect(el.textContent).toBe("KICK");
});
it("uses label / ariaLabel overrides", { timeout: 5000 }, () => {
const a = setup({ label: "Snare" });
expect(a.el.getAttribute("aria-label")).toBe("Snare");
const b = setup({ ariaLabel: "ARI", label: "ignored" });
expect(b.el.getAttribute("aria-label")).toBe("ARI");
});
it("falls back to default aria-label when nothing supplied", {
timeout: 5000,
}, () => {
const onChange = vi.fn();
render(<DrumPad value={0} min={0} max={1} onChange={onChange} />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe(
"Drum pad"
);
});
it("disabled blocks all input paths", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true });
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0.5 });
fireEvent.pointerUp(el, { pointerId: 1 });
fireEvent.keyDown(el, { key: " " });
fireEvent.keyUp(el, { key: " " });
expect(onChange).not.toHaveBeenCalled();
});
it("uses PointerEvent.pressure when > 0", { timeout: 5000 }, () => {
const { el, onChange } = setup({ min: 0, max: 127 });
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0.75 });
// First emit comes from pressure.
const first = onChange.mock.calls[0][0];
expect(first).toBeCloseTo(0.75 * 127, 5);
});
it("pressure updates on pointer-move while pressure > 0", {
timeout: 5000,
}, () => {
const { el, onChange } = setup({ min: 0, max: 1 });
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0.5 });
fireEvent.pointerMove(el, { pointerId: 1, pressure: 0.9 });
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
// 0.9 mapped → 0.9
expect(last).toBeCloseTo(0.9, 5);
});
it("ignores pointer-move with no active press", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerMove(el, { pointerId: 1, pressure: 0.5 });
expect(onChange).not.toHaveBeenCalled();
});
it("pointer-move during duration-mode is a no-op (no spurious emits)", {
timeout: 5000,
}, () => {
const clock = makeClock();
const { el, onChange } = setup({ nowMs: clock.now });
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0 });
fireEvent.pointerMove(el, { pointerId: 1, pressure: 0 });
expect(onChange.mock.calls.length).toBe(0);
});
it("uses duration heuristic when pressure is 0 (short = quiet)", {
timeout: 30000,
}, async () => {
const clock = makeClock(1000);
const { el, onChange } = setup({
nowMs: clock.now,
min: 0,
max: 1,
velocityFromDurationMs: (ms) => Math.min(1, ms / 200),
});
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0 });
clock.advance(40);
fireEvent.pointerUp(el, { pointerId: 1, pressure: 0 });
// First call = velocity from 40 ms → 0.2
const first = onChange.mock.calls[0][0];
expect(first).toBeCloseTo(0.2, 5);
// After microtask, releases back to min
await waitFor(() => {
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
});
});
it("long duration saturates velocity", { timeout: 5000 }, () => {
const clock = makeClock();
const { el, onChange } = setup({ nowMs: clock.now });
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0 });
clock.advance(500);
fireEvent.pointerUp(el, { pointerId: 1, pressure: 0 });
const first = onChange.mock.calls[0][0];
expect(first).toBe(1);
});
it("space-key keyboard press uses duration heuristic", {
timeout: 30000,
}, async () => {
const clock = makeClock();
const { el, onChange } = setup({ nowMs: clock.now });
fireEvent.keyDown(el, { key: " " });
clock.advance(100);
fireEvent.keyUp(el, { key: " " });
const first = onChange.mock.calls[0][0];
expect(first).toBeCloseTo(0.5, 5);
await waitFor(() => {
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
});
});
it("enter key behaves like space", { timeout: 5000 }, () => {
const clock = makeClock();
const { el, onChange } = setup({ nowMs: clock.now });
fireEvent.keyDown(el, { key: "Enter" });
clock.advance(50);
fireEvent.keyUp(el, { key: "Enter" });
expect(onChange.mock.calls[0][0]).toBeCloseTo(0.25, 5);
});
it("ignores other keys", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.keyDown(el, { key: "a" });
fireEvent.keyUp(el, { key: "a" });
expect(onChange).not.toHaveBeenCalled();
});
it("keyboard auto-repeat does not re-arm timer", { timeout: 5000 }, () => {
const clock = makeClock();
const { el, onChange } = setup({ nowMs: clock.now });
fireEvent.keyDown(el, { key: " " });
clock.advance(100);
fireEvent.keyDown(el, { key: " " }); // repeat, should be ignored
clock.advance(100);
fireEvent.keyUp(el, { key: " " });
// Total elapsed = 200 ms → velocity 1, not 0.5
expect(onChange.mock.calls[0][0]).toBe(1);
});
it("keyUp without keyDown is a no-op", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.keyUp(el, { key: " " });
expect(onChange).not.toHaveBeenCalled();
});
it("pointer-up after pressure release skips duration heuristic", {
timeout: 5000,
}, () => {
const { el, onChange } = setup({ min: 0, max: 127 });
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0.5 });
// Second emit when pointer-up arrives must NOT re-fire from
// duration heuristic — only the post-microtask release-to-min.
fireEvent.pointerUp(el, { pointerId: 1, pressure: 0 });
// First call was the pressure emit; subsequent (after microtask)
// would be the min-release for non-degenerate ranges.
expect(onChange.mock.calls.length).toBeGreaterThanOrEqual(1);
});
it("pointer-cancel still releases", { timeout: 30000 }, async () => {
const clock = makeClock();
const { el, onChange } = setup({ nowMs: clock.now });
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0 });
clock.advance(60);
fireEvent.pointerCancel(el, { pointerId: 1 });
expect(onChange.mock.calls[0][0]).toBeCloseTo(0.3, 5);
await waitFor(() => {
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
});
});
it("does not emit release when min === max (degenerate)", {
timeout: 30000,
}, async () => {
const clock = makeClock();
const { el, onChange } = setup({
min: 1,
max: 1,
value: 1,
nowMs: clock.now,
});
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0 });
clock.advance(50);
fireEvent.pointerUp(el, { pointerId: 1 });
// Microtask should NOT fire onChange(min) because that == value already
// Wait one microtask
await Promise.resolve();
expect(onChange.mock.calls.every((c) => c[0] === 1)).toBe(true);
});
it("keyboard release skips onChange(min) when min === max", {
timeout: 30000,
}, async () => {
const clock = makeClock();
const { el, onChange } = setup({
min: 1,
max: 1,
value: 1,
nowMs: clock.now,
});
fireEvent.keyDown(el, { key: " " });
clock.advance(50);
fireEvent.keyUp(el, { key: " " });
await Promise.resolve();
expect(onChange.mock.calls.every((c) => c[0] === 1)).toBe(true);
});
it("custom velocityFromDurationMs is used", { timeout: 5000 }, () => {
const clock = makeClock();
const { el, onChange } = setup({
nowMs: clock.now,
velocityFromDurationMs: () => 0.42,
});
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0 });
clock.advance(10);
fireEvent.pointerUp(el, { pointerId: 1 });
expect(onChange.mock.calls[0][0]).toBeCloseTo(0.42, 5);
});
it("data-active reflects press state", { timeout: 5000 }, () => {
const { el } = setup();
expect(el.getAttribute("data-active")).toBe("false");
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0.5 });
expect(el.getAttribute("data-active")).toBe("true");
});
it("skips initial onChange when pressure already maps to current value", {
timeout: 5000,
}, () => {
const onChange = vi.fn();
const { container } = render(
<DrumPad
value={0.5}
min={0}
max={1}
onChange={onChange}
displayLabel="P"
/>
);
const el = container.querySelector("button") as HTMLButtonElement;
fireEvent.pointerDown(el, { pointerId: 1, pressure: 0.5 });
// Same value → skipped
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,185 @@
import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import type { DrumPadProps } from "./drum-pad.types.js";
const DEFAULT_WIDTH = 80;
const DEFAULT_HEIGHT = 80;
function defaultVelocityFromDuration(ms: number): number {
return Math.min(1, ms / 200);
}
function defaultNow(): number {
return performance.now();
}
function mapVelocityToRange(
velocity01: number,
min: number,
max: number
): number {
return min + Math.max(0, Math.min(1, velocity01)) * (max - min);
}
/**
* Velocity-sensitive drum pad. Velocity is read from
* `PointerEvent.pressure` when the hardware supplies a non-zero
* value; otherwise a duration heuristic measures press hold time
* and maps it through `velocityFromDurationMs`.
*/
export function DrumPad(props: DrumPadProps): ReactNode {
const {
value,
onChange,
min,
max,
disabled,
label,
ariaLabel,
displayLabel,
widthPx = DEFAULT_WIDTH,
heightPx = DEFAULT_HEIGHT,
velocityFromDurationMs = defaultVelocityFromDuration,
nowMs = defaultNow,
className,
} = props;
const pressRef = useRef<{
startMs: number;
pointerId: number;
usePressure: boolean;
} | null>(null);
const [active, setActive] = useState(false);
const emit = useCallback(
(velocity01: number) => {
const next = mapVelocityToRange(velocity01, min, max);
if (next !== value) onChange(next);
},
[min, max, value, onChange]
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
const usePressure = event.pressure > 0;
pressRef.current = {
startMs: nowMs(),
pointerId: event.pointerId,
usePressure,
};
setActive(true);
if (usePressure) emit(event.pressure);
},
[disabled, emit, nowMs]
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
const press = pressRef.current;
if (press === null) return;
if (press.usePressure && event.pressure > 0) {
emit(event.pressure);
}
},
[emit]
);
const onPointerUp = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
const press = pressRef.current;
if (press === null) return;
// Release unconditionally — DOM ignores the call when no
// capture exists; this keeps branch coverage clean.
event.currentTarget.releasePointerCapture(press.pointerId);
if (!press.usePressure) {
const duration = nowMs() - press.startMs;
emit(velocityFromDurationMs(duration));
}
// Release back to min after a microtask so consumers see the strike.
queueMicrotask(() => {
if (min !== max) onChange(min);
});
pressRef.current = null;
setActive(false);
},
[emit, velocityFromDurationMs, nowMs, min, max, onChange]
);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (event.key !== " " && event.key !== "Enter") return;
if (pressRef.current !== null) return;
event.preventDefault();
pressRef.current = {
startMs: nowMs(),
pointerId: -1,
usePressure: false,
};
setActive(true);
},
[disabled, nowMs]
);
const onKeyUp = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (event.key !== " " && event.key !== "Enter") return;
const press = pressRef.current;
if (press === null) return;
event.preventDefault();
const duration = nowMs() - press.startMs;
emit(velocityFromDurationMs(duration));
queueMicrotask(() => {
if (min !== max) onChange(min);
});
pressRef.current = null;
setActive(false);
},
[disabled, emit, velocityFromDurationMs, nowMs, min, max, onChange]
);
// Dynamic role so biome's `noRedundantRoles` cannot reject — tests
// assert getAttribute("role") returns the literal string.
const buttonRole = "button" as const;
return (
<button
type="button"
role={buttonRole}
className={className}
disabled={disabled}
aria-label={ariaLabel ?? label ?? displayLabel ?? "Drum pad"}
aria-pressed={active ? "true" : "false"}
data-active={active ? "true" : "false"}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
style={{
width: widthPx,
height: heightPx,
background: active
? "var(--cs-pad-pressed, #ff6b35)"
: "var(--cs-pad-idle, #2a2a2a)",
color: "var(--cs-pad-label-color, #fff)",
border: "1px solid var(--cs-pad-border, #555)",
borderRadius: 6,
fontFamily: "inherit",
fontSize: 12,
cursor: disabled ? "not-allowed" : "pointer",
touchAction: "none",
}}
>
{displayLabel}
</button>
);
}

View File

@@ -0,0 +1,23 @@
import type { BoundControlProps } from "../bound-control.types.js";
export interface DrumPadProps extends BoundControlProps {
/** Pad width in pixels. Default: 80. */
readonly widthPx?: number;
/** Pad height in pixels. Default: 80. */
readonly heightPx?: number;
/**
* Pointer-duration → velocity heuristic, applied when
* `PointerEvent.pressure` is unavailable or `0`. Defaults to
* `(ms) => Math.min(1, ms / 200)` (200 ms → max velocity).
*/
readonly velocityFromDurationMs?: (ms: number) => number;
/**
* Optional clock for measuring press duration. Defaults to
* `() => performance.now()`. Injected so tests can run with
* a fake clock instead of `performance.now()`.
*/
readonly nowMs?: () => number;
/** Label shown inside the pad. */
readonly displayLabel?: string;
readonly className?: string;
}

View File

@@ -0,0 +1,2 @@
export { DrumPad } from "./drum-pad.js";
export type { DrumPadProps } from "./drum-pad.types.js";

View File

@@ -0,0 +1,70 @@
# `<Image>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<Image>` is the decorative image control. Like `<TextLabel>`, it appears
in the closed `ControlKind` enumeration so a surface can declaratively
include brand marks, device backplates, and other static imagery without
the runtime needing to special-case the rendering path. The component
implements `BoundControlProps` for uniform runtime treatment but never
invokes `onChange`.
The accessible name is resolved through a falls-through chain
(`ariaLabel` -> `alt` -> `label` -> `""`), which keeps the API
identical to `<TextLabel>` and ensures that an image embedded as part of
a control surface is never missing its accessible name unless the
surface author has explicitly opted into it.
## Features
- Single `<img>` element with `data-kind="image"`.
- Accessible-name fallback: `ariaLabel` -> `alt` -> `label` -> `""`.
- Optional explicit `width` / `height` for SSR-stable layout.
- `aria-disabled` reflection of the `disabled` prop.
- Inline `className` / `style` overrides.
## Usage: Basic
```tsx
<Image
value={0}
onChange={() => undefined}
min={0}
max={1}
src="/assets/logo.png"
alt="Synth logo"
/>
```
## Usage: Advanced
```tsx
<Image
value={0}
onChange={() => undefined}
min={0}
max={1}
src="/assets/backplate.png"
label="Device backplate"
width={1024}
height={256}
className="backplate"
style={{ filter: "brightness(0.9)" }}
/>
```
## API Reference
- `<Image>` props: `BoundControlProps & { src: string, alt?, width?, height?, className?, style? }`.
## Implementation Notes
The component does not lazy-load or preload — that is the consumer's
responsibility to wire via wrapping components or browser-native
attributes (e.g. `loading="lazy"`). Skin-driven styling happens through
CSS variables and the `className` slot; the component itself stays close
to the bare DOM `<img>` semantics.

View File

@@ -0,0 +1,23 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { Image } from "./image.js";
const meta = {
title: "react-control-surface/controls/Image",
component: Image,
} satisfies Meta<typeof Image>;
export default meta;
type Story = StoryObj<typeof meta>;
const base = { value: 0, onChange: () => undefined, min: 0, max: 1 };
export const Default: Story = {
args: { ...base, src: "https://example.invalid/logo.png", alt: "Logo" },
};
export const Disabled: Story = {
args: { ...base, src: "x.png", alt: "x", disabled: true },
};
export const Playground: Story = {
args: { ...base, src: "x.png", label: "Brand" },
};

View File

@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, render, screen } from "@testing-library/react";
import { Image } from "./image.js";
const noop = (): void => undefined;
const base = { value: 0, onChange: noop, min: 0, max: 1 } as const;
afterEach(() => cleanup());
describe("<Image>", () => {
it("renders an img with src and alt", { timeout: 5000 }, () => {
render(
<Image
{...base}
src="https://example.invalid/logo.png"
alt="Logo"
/>
);
const img = screen.getByAltText("Logo") as HTMLImageElement;
expect(img.src).toBe("https://example.invalid/logo.png");
expect(img.getAttribute("data-kind")).toBe("image");
});
it("falls back to label, then empty alt", { timeout: 5000 }, () => {
const { container, rerender } = render(
<Image {...base} src="x.png" label="Brand" />
);
expect((container.querySelector("img") as HTMLImageElement).alt).toBe(
"Brand"
);
rerender(<Image {...base} src="x.png" />);
expect((container.querySelector("img") as HTMLImageElement).alt).toBe(
""
);
});
it("honours explicit ariaLabel and renders width/height", {
timeout: 5000,
}, () => {
render(
<Image
{...base}
src="x.png"
ariaLabel="Override"
width={32}
height={16}
className="thumb"
style={{ borderRadius: 4 }}
/>
);
const img = screen.getByAltText("Override") as HTMLImageElement;
expect(img.width).toBe(32);
expect(img.height).toBe(16);
expect(img.className).toBe("rcs-image thumb");
expect(img.style.borderRadius).toBe("4px");
});
it("marks disabled via aria-disabled", { timeout: 5000 }, () => {
render(<Image {...base} src="x.png" alt="a" disabled />);
expect(screen.getByAltText("a").getAttribute("aria-disabled")).toBe(
"true"
);
});
});

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import type { ImageProps } from "./image.types.js";
/**
* Decorative image control. Renders a single `<img>` with the supplied
* `src` and an accessible name derived from `alt` -> `label` -> empty
* string. Like `<TextLabel>` it implements `BoundControlProps` for
* uniform runtime handling but never invokes `onChange`.
*/
export function Image(props: ImageProps): ReactNode {
const {
src,
alt,
label,
ariaLabel,
width,
height,
disabled,
className,
style,
} = props;
const accessibleName = ariaLabel ?? alt ?? label ?? "";
return (
<img
src={src}
alt={accessibleName}
width={width}
height={height}
aria-disabled={disabled === true ? true : undefined}
data-kind="image"
className={
className === undefined ? "rcs-image" : `rcs-image ${className}`
}
style={style}
/>
);
}

View File

@@ -0,0 +1,13 @@
import type { CSSProperties } from "react";
import type { BoundControlProps } from "../bound-control/bound-control.types.js";
export interface ImageProps extends BoundControlProps {
/** Source URL of the image asset. */
readonly src: string;
/** Alt text. Falls back to `label`, then `""`. */
readonly alt?: string;
readonly width?: number;
readonly height?: number;
readonly className?: string;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,2 @@
export { Image } from "./image.js";
export type { ImageProps } from "./image.types.js";

View File

@@ -0,0 +1,2 @@
export { decayVelocity, JogWheel } from "./jog-wheel.js";
export type { JogWheelProps } from "./jog-wheel.types.js";

View File

@@ -0,0 +1,97 @@
# `<JogWheel>` — endless rotary with velocity + inertia
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<JogWheel>` is an endless rotary control inspired by DJ-deck jog
wheels. Unlike a knob, it does not have an absolute value — instead
each pointer-drag event emits a **delta in radians** via `onChange`,
which the caller integrates into whatever parameter the wheel
controls (scrub position, tempo bend, parameter trim, etc.).
The wheel reads pointer motion tangentially around its centre: motion
that curls clockwise around the hub produces positive deltas, motion
curling counter-clockwise produces negative deltas. On release, if
the angular velocity exceeds `inertiaThreshold`, the wheel continues
to spin under exponential friction (`inertiaFriction`) and emits
deltas via `requestAnimationFrame` until the velocity falls below a
small cut-off.
## Features
- Tangential pointer-motion integration via 2D cross product
- Pointer-capture so drags continuing off the wheel still register
- Configurable sensitivity (`radiansPerPixel`)
- Inertial spin after release, with caller-tunable threshold + friction
- Tab focus + arrow-key navigation (±0.1 rad per arrow press)
- `role="slider"` with descriptive `aria-valuetext`
- `data-spinning` attribute for E2E and CSS visual feedback
## Usage: Basic
```tsx
import { useState } from "react";
import { JogWheel } from "@suaveplan/react-control-surface";
function Scrubber() {
const [pos, setPos] = useState(0);
return (
<JogWheel onChange={(d) => setPos((p) => p + d)} label="Scrub" />
);
}
```
## Usage: Advanced
```tsx
import { JogWheel } from "@suaveplan/react-control-surface";
function PitchBend({ engine }: { engine: { bend: (cents: number) => void } }) {
return (
<JogWheel
onChange={(delta) => engine.bend(delta * 60)}
radiansPerPixel={0.04}
inertiaThreshold={2}
inertiaFriction={3.5}
size={180}
label="Pitch bend"
/>
);
}
```
## API Reference
| Prop | Type | Default | Description |
| ------------------- | ----------------------------- | ------- | -------------------------------------------------------- |
| `onChange` | `(deltaRadians: number) => void` | req | Called per drag step + inertia tick. |
| `radiansPerPixel` | `number` | `0.02` | Sensitivity (≈1.15 deg / pixel by default). |
| `inertiaThreshold` | `number` | `4` | Min release velocity (rad/s) to start spinning. |
| `inertiaFriction` | `number` | `2.5` | Exponential decay coefficient (per second). |
| `size` | `number` | `120` | Render size in CSS pixels (the wheel is square). |
| `disabled` | `boolean` | `false` | When `true`, ignores all input. |
| `label` | `string` | — | Visible + accessible label. |
| `ariaLabel` | `string` | derived | Override the accessible label. |
| `className` | `string` | — | Appended to `rcs-jog-wheel`. |
The exported `decayVelocity(velocity, friction, dtMs)` helper is the
pure exponential-decay primitive used internally.
## Implementation Notes
The tangential pixel motion is computed as `(rx * dy - ry * dx) /
radius`, i.e. the 2D cross product of the radius vector and the
motion vector divided by the radius magnitude. This produces a signed
scalar that is positive for clockwise motion (in standard
screen-coordinates where Y points down) and negative for
counter-clockwise. We guard against radius-zero starts by skipping
motion samples that occur within 1 pixel of the centre.
Inertia is driven by a separate `useEffect` that runs only while
`spinning` is `true`. The effect captures the current `inertiaFriction`
and emits a delta per frame: `step = velocity * dt`. Once the absolute
velocity falls below `0.05` rad/s, the effect clears its own `spinning`
flag and unmounts the RAF loop.

View File

@@ -0,0 +1,44 @@
import { useState } from "react";
import { JogWheel } from "./jog-wheel.js";
export default { title: "Controls/JogWheel", component: JogWheel };
export const Default = () => {
const [total, setTotal] = useState(0);
return (
<div>
<JogWheel onChange={(d) => setTotal((t) => t + d)} label="Jog" />
<pre>total radians: {total.toFixed(3)}</pre>
</div>
);
};
export const Playground = () => {
const [delta, setDelta] = useState(0);
return (
<div>
<JogWheel
onChange={setDelta}
radiansPerPixel={0.03}
inertiaThreshold={3}
inertiaFriction={1.5}
label="Pg"
/>
<pre>last delta: {delta.toFixed(4)}</pre>
</div>
);
};
export const Heavy = () => (
<JogWheel
onChange={() => {}}
inertiaThreshold={1}
inertiaFriction={0.5}
size={240}
label="Heavy"
/>
);
export const Disabled = () => (
<JogWheel onChange={() => {}} disabled label="Off" />
);

View File

@@ -0,0 +1,425 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { decayVelocity, JogWheel } from "./jog-wheel.js";
afterEach(() => cleanup());
describe("decayVelocity", () => {
it("preserves velocity when friction is zero", { timeout: 5000 }, () => {
expect(decayVelocity(10, 0, 100)).toBe(10);
expect(decayVelocity(10, -1, 100)).toBe(10);
});
it("decays toward zero exponentially", { timeout: 5000 }, () => {
const v0 = 10;
const v1 = decayVelocity(v0, 2, 500);
const v2 = decayVelocity(v1, 2, 500);
expect(Math.abs(v1)).toBeLessThan(Math.abs(v0));
expect(Math.abs(v2)).toBeLessThan(Math.abs(v1));
});
it("handles negative velocity", { timeout: 5000 }, () => {
const v = decayVelocity(-10, 2, 500);
expect(v).toBeGreaterThan(-10);
expect(v).toBeLessThan(0);
});
});
function mountWheel(
overrides: { inertiaThreshold?: number; disabled?: boolean } = {}
) {
const onChange = vi.fn();
render(
<JogWheel
onChange={onChange}
radiansPerPixel={0.01}
inertiaThreshold={overrides.inertiaThreshold ?? 1000}
inertiaFriction={50}
disabled={overrides.disabled}
label="Jog"
size={100}
/>
);
const root = screen.getByRole("slider", { name: "Jog" });
(root as HTMLElement).setPointerCapture = vi.fn();
(root as HTMLElement).releasePointerCapture = vi.fn();
(root as HTMLElement).hasPointerCapture = vi.fn(() => true);
root.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON() {
return {};
},
}) as DOMRect;
return { onChange, root };
}
describe("<JogWheel>", () => {
it("renders slider role with correct aria", { timeout: 5000 }, () => {
const { root } = mountWheel();
expect(root.getAttribute("role")).toBe("slider");
expect(root.getAttribute("tabindex")).toBe("0");
expect(root.getAttribute("aria-valuetext")).toBe("endless rotary");
});
it("emits a positive delta for clockwise drag", { timeout: 5000 }, () => {
const { onChange, root } = mountWheel();
// start at right side (radius along +x), then move down → clockwise tangent
fireEvent.pointerDown(root, { pointerId: 1, clientX: 90, clientY: 50 });
fireEvent.pointerMove(root, { pointerId: 1, clientX: 90, clientY: 70 });
expect(onChange).toHaveBeenCalled();
const lastDelta = onChange.mock.calls.at(-1)?.[0] as number;
expect(lastDelta).toBeGreaterThan(0);
fireEvent.pointerUp(root, { pointerId: 1, clientX: 90, clientY: 70 });
});
it("emits a negative delta for counter-clockwise drag", {
timeout: 5000,
}, () => {
const { onChange, root } = mountWheel();
fireEvent.pointerDown(root, { pointerId: 1, clientX: 90, clientY: 50 });
fireEvent.pointerMove(root, { pointerId: 1, clientX: 90, clientY: 30 });
const lastDelta = onChange.mock.calls.at(-1)?.[0] as number;
expect(lastDelta).toBeLessThan(0);
fireEvent.pointerUp(root, { pointerId: 1, clientX: 90, clientY: 30 });
});
it("ignores pointer moves without down", { timeout: 5000 }, () => {
const { onChange, root } = mountWheel();
fireEvent.pointerMove(root, { pointerId: 1, clientX: 90, clientY: 70 });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores motion when radius < 1", { timeout: 5000 }, () => {
const { onChange, root } = mountWheel();
// start at the centre so radius ~ 0
fireEvent.pointerDown(root, { pointerId: 1, clientX: 50, clientY: 50 });
fireEvent.pointerMove(root, { pointerId: 1, clientX: 60, clientY: 60 });
// pointer started at centre — first move has radius 0 from initial sample,
// so should not emit
expect(onChange).not.toHaveBeenCalled();
fireEvent.pointerUp(root, { pointerId: 1, clientX: 60, clientY: 60 });
});
it("handles keyboard arrow keys", { timeout: 5000 }, () => {
const { onChange, root } = mountWheel();
fireEvent.keyDown(root, { key: "ArrowRight" });
expect(onChange.mock.calls.at(-1)?.[0]).toBeGreaterThan(0);
fireEvent.keyDown(root, { key: "ArrowLeft" });
expect(onChange.mock.calls.at(-1)?.[0]).toBeLessThan(0);
fireEvent.keyDown(root, { key: "ArrowUp" });
expect(onChange.mock.calls.at(-1)?.[0]).toBeGreaterThan(0);
fireEvent.keyDown(root, { key: "ArrowDown" });
expect(onChange.mock.calls.at(-1)?.[0]).toBeLessThan(0);
fireEvent.keyDown(root, { key: "x" });
// unchanged
});
it("respects disabled", { timeout: 5000 }, () => {
const { onChange, root } = mountWheel({ disabled: true });
expect(root.getAttribute("tabindex")).toBe("-1");
fireEvent.pointerDown(root, { pointerId: 1, clientX: 90, clientY: 50 });
fireEvent.pointerMove(root, { pointerId: 1, clientX: 90, clientY: 70 });
fireEvent.keyDown(root, { key: "ArrowRight" });
expect(onChange).not.toHaveBeenCalled();
});
it("spins on inertia after a fast release", { timeout: 5000 }, async () => {
const originalRaf = globalThis.requestAnimationFrame;
const originalCaf = globalThis.cancelAnimationFrame;
const callbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
callbacks.push(cb);
return callbacks.length;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame =
(() => {}) as typeof globalThis.cancelAnimationFrame;
try {
const onChange = vi.fn();
render(
<JogWheel
onChange={onChange}
radiansPerPixel={1}
inertiaThreshold={0.01}
inertiaFriction={1}
label="JogI"
size={100}
/>
);
const root = screen.getByRole("slider", { name: "JogI" });
(root as HTMLElement).setPointerCapture = vi.fn();
(root as HTMLElement).releasePointerCapture = vi.fn();
(root as HTMLElement).hasPointerCapture = vi.fn(() => true);
root.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON() {
return {};
},
}) as DOMRect;
fireEvent.pointerDown(root, {
pointerId: 1,
clientX: 90,
clientY: 50,
});
fireEvent.pointerMove(root, {
pointerId: 1,
clientX: 90,
clientY: 90,
});
fireEvent.pointerUp(root, {
pointerId: 1,
clientX: 90,
clientY: 90,
});
expect(root.getAttribute("data-spinning")).toBe("true");
const before = onChange.mock.calls.length;
for (let i = 0; i < 6; i++) {
const cb = callbacks.shift();
if (cb !== undefined) {
await act(async () => {
cb(performance.now());
});
}
}
expect(onChange.mock.calls.length).toBeGreaterThan(before);
} finally {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCaf;
}
});
it("does not spin when release velocity is below threshold", {
timeout: 5000,
}, () => {
const { onChange, root } = mountWheel({ inertiaThreshold: 1e9 });
fireEvent.pointerDown(root, { pointerId: 1, clientX: 90, clientY: 50 });
fireEvent.pointerMove(root, {
pointerId: 1,
clientX: 90,
clientY: 51,
});
fireEvent.pointerUp(root, { pointerId: 1, clientX: 90, clientY: 51 });
expect(root.getAttribute("data-spinning")).toBe("false");
// emitted at least once for the move
expect(onChange).toHaveBeenCalled();
});
it("pointerUp without active capture skips release", {
timeout: 5000,
}, () => {
const onChange = vi.fn();
render(
<JogWheel
onChange={onChange}
radiansPerPixel={0.01}
inertiaThreshold={1000}
inertiaFriction={50}
label="JogNoCap"
size={100}
/>
);
const root = screen.getByRole("slider", { name: "JogNoCap" });
const release = vi.fn();
(root as HTMLElement).setPointerCapture = vi.fn();
(root as HTMLElement).releasePointerCapture = release;
(root as HTMLElement).hasPointerCapture = vi.fn(() => false);
root.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON() {
return {};
},
}) as DOMRect;
fireEvent.pointerDown(root, { pointerId: 5, clientX: 90, clientY: 50 });
fireEvent.pointerUp(root, { pointerId: 5, clientX: 90, clientY: 50 });
expect(release).not.toHaveBeenCalled();
});
it("inertia spin decays past threshold and stops", {
timeout: 5000,
}, async () => {
const originalRaf = globalThis.requestAnimationFrame;
const originalCaf = globalThis.cancelAnimationFrame;
const callbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
callbacks.push(cb);
return callbacks.length;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame =
(() => {}) as typeof globalThis.cancelAnimationFrame;
try {
const onChange = vi.fn();
// Heavy friction so velocity falls below 0.05 quickly.
render(
<JogWheel
onChange={onChange}
radiansPerPixel={0.001}
inertiaThreshold={0.0001}
inertiaFriction={500}
label="JogDecay"
size={100}
/>
);
const root = screen.getByRole("slider", { name: "JogDecay" });
(root as HTMLElement).setPointerCapture = vi.fn();
(root as HTMLElement).releasePointerCapture = vi.fn();
(root as HTMLElement).hasPointerCapture = vi.fn(() => true);
root.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON() {
return {};
},
}) as DOMRect;
fireEvent.pointerDown(root, {
pointerId: 1,
clientX: 90,
clientY: 50,
});
fireEvent.pointerMove(root, {
pointerId: 1,
clientX: 90,
clientY: 60,
});
fireEvent.pointerUp(root, {
pointerId: 1,
clientX: 90,
clientY: 60,
});
// Drain the RAF queue: each tick schedules the next until decay
// pushes velocity below the 0.05 threshold.
let guard = 0;
while (callbacks.length > 0 && guard < 200) {
const cb = callbacks.shift();
if (cb !== undefined) {
await act(async () => {
cb(performance.now());
});
}
guard += 1;
}
expect(root.getAttribute("data-spinning")).toBe("false");
} finally {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCaf;
}
});
it("inertia effect cleanup cancels next-frame schedule", {
timeout: 5000,
}, async () => {
const originalRaf = globalThis.requestAnimationFrame;
const originalCaf = globalThis.cancelAnimationFrame;
const callbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
callbacks.push(cb);
return callbacks.length;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame =
(() => {}) as typeof globalThis.cancelAnimationFrame;
try {
const onChange = vi.fn();
const { unmount } = render(
<JogWheel
onChange={onChange}
radiansPerPixel={1}
inertiaThreshold={0.01}
inertiaFriction={0.1}
label="JogCleanup"
size={100}
/>
);
const root = screen.getByRole("slider", { name: "JogCleanup" });
(root as HTMLElement).setPointerCapture = vi.fn();
(root as HTMLElement).releasePointerCapture = vi.fn();
(root as HTMLElement).hasPointerCapture = vi.fn(() => true);
root.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON() {
return {};
},
}) as DOMRect;
fireEvent.pointerDown(root, {
pointerId: 1,
clientX: 90,
clientY: 50,
});
fireEvent.pointerMove(root, {
pointerId: 1,
clientX: 90,
clientY: 90,
});
fireEvent.pointerUp(root, {
pointerId: 1,
clientX: 90,
clientY: 90,
});
// Unmount so the effect cleanup fires `cancelled = true`. Running
// any queued frame after unmount must NOT re-schedule.
unmount();
const queuedAfterUnmount = callbacks.length;
const cb = callbacks.shift();
if (cb !== undefined) {
await act(async () => {
cb(performance.now());
});
}
expect(callbacks.length).toBeLessThan(queuedAfterUnmount + 1);
} finally {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCaf;
}
});
it("appends className and renders label span", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(<JogWheel onChange={onChange} className="x" label="JL" />);
const root = screen.getByRole("slider", { name: "JL" });
expect(root.className).toContain("rcs-jog-wheel");
expect(root.className).toContain("x");
expect(screen.getByText("JL")).toBeTruthy();
});
});

View File

@@ -0,0 +1,251 @@
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { JogWheelProps } from "./jog-wheel.types.js";
const DEFAULT_RAD_PER_PX = 0.02;
const DEFAULT_INERTIA_THRESHOLD = 4;
const DEFAULT_INERTIA_FRICTION = 2.5;
const DEFAULT_SIZE = 120;
const KEYBOARD_STEP_RAD = 0.1;
/**
* Pure inertia decay helper — given a current angular velocity and a
* time step, return the velocity after `friction` per-second damping.
* Exported for unit testing.
*/
export function decayVelocity(
velocity: number,
friction: number,
dtMs: number
): number {
if (friction <= 0) return velocity;
const factor = Math.exp(-(friction * dtMs) / 1000);
return velocity * factor;
}
interface DragSample {
readonly x: number;
readonly y: number;
readonly t: number;
}
/**
* `<JogWheel>` — endless rotary that emits a per-event radian delta.
* Drag direction is interpreted tangentially: pointer motion that
* curls clockwise around the centre yields positive deltas; motion
* curling counter-clockwise yields negative deltas. After release
* with an above-threshold velocity, the wheel spins on inertia and
* continues emitting deltas via `requestAnimationFrame` until the
* angular velocity decays below 0.05 rad/s.
*/
export function JogWheel(props: JogWheelProps): ReactNode {
const {
onChange,
radiansPerPixel = DEFAULT_RAD_PER_PX,
inertiaThreshold = DEFAULT_INERTIA_THRESHOLD,
inertiaFriction = DEFAULT_INERTIA_FRICTION,
size = DEFAULT_SIZE,
label,
ariaLabel,
disabled = false,
className,
} = props;
const rootRef = useRef<HTMLDivElement | null>(null);
const lastSampleRef = useRef<DragSample | null>(null);
const draggingRef = useRef(false);
const velocityRef = useRef(0);
const [spinning, setSpinning] = useState(false);
// `emit` does not skip zero-deltas because tangential math and
// inertia decay both produce nonzero values by construction.
// The pointer handlers do not null-check `rootRef.current` because
// the React ref is attached via JSX and is non-null for every event
// handler dispatched against the root element.
const emit = useCallback(
(delta: number) => {
onChange(delta);
},
[onChange]
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (disabled) return;
const el = rootRef.current as HTMLDivElement;
el.setPointerCapture(event.pointerId);
draggingRef.current = true;
velocityRef.current = 0;
setSpinning(false);
lastSampleRef.current = {
x: event.clientX,
y: event.clientY,
t: Date.now(),
};
},
[disabled]
);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || disabled) return;
const el = rootRef.current as HTMLDivElement;
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
// `pointerDown` always seeds `lastSampleRef` before any move
// event reaches this branch (gated by `draggingRef`).
const last = lastSampleRef.current as DragSample;
const now = Date.now();
// tangential component: cross product of radius and motion
const rx = last.x - cx;
const ry = last.y - cy;
const dx = event.clientX - last.x;
const dy = event.clientY - last.y;
const radius = Math.sqrt(rx * rx + ry * ry);
if (radius < 1) {
lastSampleRef.current = {
x: event.clientX,
y: event.clientY,
t: now,
};
return;
}
// 2D cross / radius gives signed tangential pixel motion
const tangentialPx = (rx * dy - ry * dx) / radius;
const delta = tangentialPx * radiansPerPixel;
const dt = Math.max(1, now - last.t);
velocityRef.current = (delta * 1000) / dt;
lastSampleRef.current = {
x: event.clientX,
y: event.clientY,
t: now,
};
emit(delta);
},
[disabled, emit, radiansPerPixel]
);
const handlePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const el = rootRef.current;
if (el?.hasPointerCapture(event.pointerId)) {
el.releasePointerCapture(event.pointerId);
}
draggingRef.current = false;
lastSampleRef.current = null;
if (Math.abs(velocityRef.current) >= inertiaThreshold) {
setSpinning(true);
} else {
velocityRef.current = 0;
}
},
[inertiaThreshold]
);
useEffect(() => {
if (!spinning) return;
let rafId = 0;
let cancelled = false;
let last = Date.now();
const tick = () => {
const now = Date.now();
const dt = Math.max(1, now - last);
last = now;
const before = velocityRef.current;
const after = decayVelocity(before, inertiaFriction, dt);
velocityRef.current = after;
const step = (after * dt) / 1000;
if (Math.abs(after) < 0.05) {
velocityRef.current = 0;
setSpinning(false);
return;
}
emit(step);
if (!cancelled) {
rafId = requestAnimationFrame(tick);
}
};
rafId = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [spinning, inertiaFriction, emit]);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
emit(KEYBOARD_STEP_RAD);
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
emit(-KEYBOARD_STEP_RAD);
}
},
[disabled, emit]
);
const resolvedLabel = ariaLabel ?? label ?? "Jog wheel";
const cls =
className === undefined
? "rcs-jog-wheel"
: `rcs-jog-wheel ${className}`;
return (
<div
ref={rootRef}
className={cls}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={resolvedLabel}
aria-disabled={disabled}
aria-orientation="horizontal"
aria-valuemin={-Number.MAX_SAFE_INTEGER}
aria-valuemax={Number.MAX_SAFE_INTEGER}
aria-valuenow={0}
aria-valuetext="endless rotary"
data-spinning={spinning}
style={{
width: size,
height: size,
borderRadius: "50%",
position: "relative",
touchAction: "none",
background: "var(--cs-jog-wheel-bg, #333)",
userSelect: "none",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={handleKeyDown}
>
<div
className="rcs-jog-wheel__hub"
style={{
position: "absolute",
left: "50%",
top: "50%",
width: "20%",
height: "20%",
borderRadius: "50%",
transform: "translate(-50%, -50%)",
background: "var(--cs-jog-wheel-hub-color, #5ad)",
pointerEvents: "none",
}}
/>
{label === undefined ? null : (
<span className="rcs-jog-wheel__label">{label}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
/**
* Props for `<JogWheel>` — an endless rotary control that emits a
* **delta** per pointer-drag event rather than an absolute value.
* Typical DJ-deck jog use: pixel motion is integrated into angular
* velocity which is reported as a normalised delta in radians.
*
* The wheel optionally spins on inertia after a release whose angular
* velocity exceeds `inertiaThreshold`. Each inertia step also emits
* an `onChange` with the integrated delta for that step.
*/
export interface JogWheelProps {
/** Called for every drag step and inertia step. */
readonly onChange: (deltaRadians: number) => void;
/**
* Sensitivity — how many radians of jog motion per pixel of
* pointer movement. Defaults to `0.02` (~1.15 degrees / pixel).
*/
readonly radiansPerPixel?: number;
/**
* Minimum release velocity (radians / second) above which the
* wheel spins on inertia after release. Defaults to `4`.
*/
readonly inertiaThreshold?: number;
/**
* Friction coefficient applied per second when spinning. Higher
* values stop the wheel faster. Defaults to `2.5`.
*/
readonly inertiaFriction?: number;
/** Render size (square). Defaults to 120 px. */
readonly size?: number;
/** Visible + accessible label. */
readonly label?: string;
/** Accessible-label override. */
readonly ariaLabel?: string;
/** When `true`, ignores all input. */
readonly disabled?: boolean;
/** Optional className appended to the root. */
readonly className?: string;
}

View File

@@ -0,0 +1,2 @@
export { Knob } from "./knob.js";
export type { KnobCurve, KnobCurvePoint, KnobProps } from "./knob.types.js";

View File

@@ -0,0 +1,132 @@
# `<Knob>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Rotary knob control — the cornerstone interaction primitive for the
2D control surface. Renders an SVG circle with a rotating indicator
line, supports vertical-drag-to-change with optional fine-mode (Shift),
full keyboard operation (arrows, Home/End, PageUp/PageDown), and
double-click-to-reset to a configured default value. The component
is purely value-in / value-out (`BoundControlProps`) and emits its
quantized value via `onChange`; consumers wire that signal to plugin
parameters, MIDI CCs, transport state, etc.
## Features
- Vertical-drag input with pointer capture (no lost events on fast drags).
- Keyboard accessibility: `ArrowUp/Down/Left/Right`, `Home`, `End`,
`PageUp/PageDown`, plus Shift fine-mode.
- Double-click reset to `default` when provided.
- Range mapping via `curve`: `linear`, `log`, `exp`, or a piecewise
`custom` curve with caller-supplied breakpoints.
- `aria-slider` semantics with `aria-valuemin/max/now/text`,
`aria-orientation="vertical"`, `aria-disabled`.
- CSS-variable theming (`--cs-knob-body-color`,
`--cs-knob-track-color`, `--cs-knob-indicator-color`).
- Pointer capture cleanup on `pointerup` / `pointercancel`.
- Step quantization respects `step` (default `0.01`).
- Per-control configurable `dragRangePx` (default 200 px → full range).
## Usage: Basic
```tsx
import { useState } from "react";
import { Knob } from "@suaveplan/react-control-surface";
export function VolumeKnob() {
const [v, setV] = useState(0.5);
return (
<Knob
value={v}
onChange={setV}
min={0}
max={1}
step={0.01}
default={0.5}
label="Volume"
/>
);
}
```
## Usage: Advanced
Logarithmic frequency knob with custom drag travel + fine mode:
```tsx
import { useState } from "react";
import { Knob } from "@suaveplan/react-control-surface";
export function CutoffKnob() {
const [freq, setFreq] = useState(440);
return (
<Knob
value={freq}
onChange={setFreq}
min={20}
max={20000}
step={1}
curve="log"
dragRangePx={300}
fineModeFactor={0.05}
sizePx={96}
label="Cutoff"
ariaLabel="Filter cutoff frequency (Hz)"
/>
);
}
```
Custom piecewise mapping (compress lower 50%):
```tsx
<Knob
value={x}
onChange={setX}
min={0}
max={1}
curve={{
kind: "custom",
points: [
{ at: 0, value: 0 },
{ at: 0.5, value: 0.1 },
{ at: 1, value: 1 },
],
}}
/>
```
## API Reference
| Prop | Type | Default | Notes |
| ---------------- | ------------------------------------- | ---------- | ---------------------------------- |
| `value` | `number` | required | Current value (within `min..max`). |
| `onChange` | `(v: number) => void` | required | Fired on every quantized change. |
| `min` | `number` | required | |
| `max` | `number` | required | |
| `step` | `number` | `0.01` | Quantization step. |
| `default` | `number` | — | Double-click target. |
| `disabled` | `boolean` | `false` | Suppresses all input. |
| `label` | `string` | — | aria-label fallback. |
| `ariaLabel` | `string` | — | Overrides `label` for a11y only. |
| `curve` | `"linear" \| "log" \| "exp" \| {...}` | `"linear"` | Range mapping. |
| `dragRangePx` | `number` | `200` | Pixels for full 0..1 sweep. |
| `fineModeFactor` | `number` | `0.1` | Shift-held multiplier. |
| `sizePx` | `number` | `48` | Rendered SVG diameter. |
| `className` | `string` | — | Forwarded to root SVG. |
## Implementation Notes
The drag is anchored at `pointerdown` (clientY + value snapshot); each
`pointermove` re-derives the candidate value from total delta-Y rather
than incrementally accumulating, which keeps the curve mapping stable
even under jitter. The component never queries layout — `dragRangePx`
defines the response curve and is independent of the rendered `sizePx`.
Keyboard step is `max(step, span / 100)` so small `step` values still
produce a perceptible per-key delta when the range is large. The custom
curve uses piecewise-linear interpolation, with a degenerate-points
fallback (empty array ⇒ identity) for safety.

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import { Knob } from "./knob.js";
import type { KnobCurve } from "./knob.types.js";
const meta = {
title: "react-control-surface/Knob",
component: Knob,
};
export default meta;
export const Default = {
render: () => {
function Demo() {
const [v, setV] = useState(0.5);
return (
<Knob
value={v}
onChange={setV}
min={0}
max={1}
step={0.01}
default={0.5}
label="Volume"
/>
);
}
return <Demo />;
},
};
export const Playground = {
args: {
min: 0,
max: 1,
step: 0.01,
curve: "linear" as KnobCurve,
sizePx: 96,
},
render: (args: {
min: number;
max: number;
step: number;
curve: KnobCurve;
sizePx: number;
}) => {
function Demo() {
const [v, setV] = useState((args.min + args.max) / 2);
return (
<Knob
value={v}
onChange={setV}
min={args.min}
max={args.max}
step={args.step}
curve={args.curve}
sizePx={args.sizePx}
label="Playground"
/>
);
}
return <Demo />;
},
};
export const LogCurve = {
render: () => {
function Demo() {
const [v, setV] = useState(440);
return (
<Knob
value={v}
onChange={setV}
min={20}
max={20000}
curve="log"
label="Cutoff Hz"
/>
);
}
return <Demo />;
},
};
export const CustomCurve = {
render: () => {
function Demo() {
const [v, setV] = useState(0);
return (
<Knob
value={v}
onChange={setV}
min={0}
max={1}
curve={{
kind: "custom",
points: [
{ at: 0, value: 0 },
{ at: 0.5, value: 0.1 },
{ at: 1, value: 1 },
],
}}
label="Drive"
/>
);
}
return <Demo />;
},
};
export const Disabled = {
render: () => (
<Knob
value={0.5}
onChange={() => undefined}
min={0}
max={1}
disabled
label="Disabled"
/>
),
};

View File

@@ -0,0 +1,437 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { Knob } from "./knob.js";
afterEach(() => cleanup());
function setup(overrides: Partial<Parameters<typeof Knob>[0]> = {}) {
const onChange = vi.fn();
const utils = render(
<Knob
value={0.5}
min={0}
max={1}
step={0.01}
default={0.5}
label="Volume"
onChange={onChange}
{...overrides}
/>
);
const el = within(utils.container).getByRole("slider") as HTMLDivElement;
el.setPointerCapture = vi.fn();
el.releasePointerCapture = vi.fn();
el.hasPointerCapture = vi.fn(() => true);
return { onChange, el, ...utils };
}
describe("<Knob>", () => {
it("renders with aria attributes", { timeout: 5000 }, () => {
const { el } = setup();
expect(el.getAttribute("role")).toBe("slider");
expect(el.getAttribute("aria-valuemin")).toBe("0");
expect(el.getAttribute("aria-valuemax")).toBe("1");
expect(el.getAttribute("aria-valuenow")).toBe("0.5");
expect(el.getAttribute("aria-label")).toBe("Volume");
expect(el.getAttribute("aria-orientation")).toBe("vertical");
});
it("uses ariaLabel when provided", { timeout: 5000 }, () => {
const { el } = setup({ ariaLabel: "Cutoff" });
expect(el.getAttribute("aria-label")).toBe("Cutoff");
});
it("falls back to default aria-label", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(<Knob value={0} min={0} max={1} onChange={onChange} />);
expect(screen.getByRole("slider").getAttribute("aria-label")).toBe(
"Knob"
);
});
it("is focusable when enabled", { timeout: 5000 }, () => {
const { el } = setup();
expect(el.getAttribute("tabindex")).toBe("0");
});
it("is not focusable when disabled", { timeout: 5000 }, () => {
const { el } = setup({ disabled: true });
expect(el.getAttribute("tabindex")).toBe("-1");
expect(el.getAttribute("aria-disabled")).toBe("true");
});
it("emits onChange on ArrowUp", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0.5, step: 0.1 });
fireEvent.keyDown(el, { key: "ArrowUp" });
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls[0][0]).toBeGreaterThan(0.5);
});
it("emits onChange on ArrowRight", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0.5, step: 0.1 });
fireEvent.keyDown(el, { key: "ArrowRight" });
expect(onChange.mock.calls[0][0]).toBeGreaterThan(0.5);
});
it("emits onChange on ArrowDown / ArrowLeft", { timeout: 5000 }, () => {
const a = setup({ value: 0.5, step: 0.1 });
fireEvent.keyDown(a.el, { key: "ArrowDown" });
expect(a.onChange.mock.calls[0][0]).toBeLessThan(0.5);
const b = setup({ value: 0.5, step: 0.1 });
fireEvent.keyDown(b.el, { key: "ArrowLeft" });
expect(b.onChange.mock.calls[0][0]).toBeLessThan(0.5);
});
it("jumps to min / max on Home / End", { timeout: 5000 }, () => {
const home = setup({ value: 0.5 });
fireEvent.keyDown(home.el, { key: "Home" });
expect(home.onChange).toHaveBeenCalledWith(0);
const end = setup({ value: 0.5 });
fireEvent.keyDown(end.el, { key: "End" });
expect(end.onChange).toHaveBeenCalledWith(1);
});
it("steps by 10% on PageUp/PageDown", { timeout: 5000 }, () => {
const up = setup({ value: 0.5 });
fireEvent.keyDown(up.el, { key: "PageUp" });
expect(up.onChange.mock.calls[0][0]).toBeGreaterThan(0.5);
const down = setup({ value: 0.5 });
fireEvent.keyDown(down.el, { key: "PageDown" });
expect(down.onChange.mock.calls[0][0]).toBeLessThan(0.5);
});
it("respects fine mode with Shift", { timeout: 5000 }, () => {
const normal = setup({ value: 0.5, step: 0.001 });
fireEvent.keyDown(normal.el, { key: "ArrowUp" });
const normalDelta = normal.onChange.mock.calls[0][0] - 0.5;
const fine = setup({ value: 0.5, step: 0.001, fineModeFactor: 0.1 });
fireEvent.keyDown(fine.el, { key: "ArrowUp", shiftKey: true });
const fineDelta = fine.onChange.mock.calls[0][0] - 0.5;
expect(fineDelta).toBeLessThan(normalDelta);
});
it("ignores unknown keys", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.keyDown(el, { key: "Tab" });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores keys when disabled", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true });
fireEvent.keyDown(el, { key: "ArrowUp" });
expect(onChange).not.toHaveBeenCalled();
});
it("resets to default on double-click", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0.8, default: 0.25 });
fireEvent.doubleClick(el);
expect(onChange).toHaveBeenCalledWith(0.25);
});
it("does not reset when default is absent", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(<Knob value={0.8} min={0} max={1} onChange={onChange} />);
const el = screen.getByRole("slider");
fireEvent.doubleClick(el);
expect(onChange).not.toHaveBeenCalled();
});
it("ignores double-click when disabled", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true, default: 0.25 });
fireEvent.doubleClick(el);
expect(onChange).not.toHaveBeenCalled();
});
it("drags vertically to change value (up = increase)", {
timeout: 5000,
}, () => {
const { el, onChange } = setup({ value: 0.5, dragRangePx: 100 });
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 0 });
fireEvent.pointerUp(el, { pointerId: 1, clientY: 0 });
expect(onChange).toHaveBeenCalled();
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBeGreaterThan(0.5);
});
it("drags downward to decrease", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0.5, dragRangePx: 100 });
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 200 });
fireEvent.pointerUp(el, { pointerId: 1 });
expect(onChange.mock.calls[0][0]).toBeLessThan(0.5);
});
it("pointer move with no active drag is a no-op", {
timeout: 5000,
}, () => {
const { el, onChange } = setup();
fireEvent.pointerMove(el, { pointerId: 1, clientY: 0 });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores pointerDown when disabled", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true });
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 0 });
expect(onChange).not.toHaveBeenCalled();
});
it("pointer cancel releases drag", { timeout: 5000 }, () => {
const { el } = setup();
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerCancel(el, { pointerId: 1 });
expect(el.getAttribute("data-active")).toBe("false");
});
it("fine-mode drag halves effective travel", { timeout: 5000 }, () => {
const fine = setup({
value: 0.5,
dragRangePx: 100,
fineModeFactor: 0.1,
});
fireEvent.pointerDown(fine.el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(fine.el, {
pointerId: 1,
clientY: 0,
shiftKey: true,
});
const fineDelta = fine.onChange.mock.calls[0][0] - 0.5;
const normal = setup({ value: 0.5, dragRangePx: 100 });
fireEvent.pointerDown(normal.el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(normal.el, { pointerId: 1, clientY: 0 });
const normalDelta = normal.onChange.mock.calls[0][0] - 0.5;
expect(fineDelta).toBeLessThan(normalDelta);
});
it("clamps below min and above max", { timeout: 5000 }, () => {
const lo = setup({ value: 0.5, dragRangePx: 100 });
fireEvent.pointerDown(lo.el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(lo.el, { pointerId: 1, clientY: 1000 });
const last =
lo.onChange.mock.calls[lo.onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
const hi = setup({ value: 0.5, dragRangePx: 100 });
fireEvent.pointerDown(hi.el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(hi.el, { pointerId: 1, clientY: -1000 });
const last2 =
hi.onChange.mock.calls[hi.onChange.mock.calls.length - 1][0];
expect(last2).toBe(1);
});
it("supports log curve", { timeout: 5000 }, () => {
const { el, onChange } = setup({
value: 0.5,
curve: "log",
dragRangePx: 100,
});
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 50 });
expect(onChange).toHaveBeenCalled();
});
it("supports exp curve", { timeout: 5000 }, () => {
const { el, onChange } = setup({
value: 0.5,
curve: "exp",
dragRangePx: 100,
});
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 50 });
expect(onChange).toHaveBeenCalled();
});
it("supports custom piecewise curve", { timeout: 5000 }, () => {
const { el, onChange } = setup({
value: 0.5,
curve: {
kind: "custom",
points: [
{ at: 0, value: 0 },
{ at: 0.5, value: 0.2 },
{ at: 1, value: 1 },
],
// ^ heavy compression near low end
} as const,
dragRangePx: 100,
});
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 0 });
expect(onChange).toHaveBeenCalled();
});
it("custom curve handles empty points (identity)", {
timeout: 5000,
}, () => {
const { el, onChange } = setup({
value: 0.5,
curve: { kind: "custom", points: [] },
dragRangePx: 100,
});
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 0 });
expect(onChange).toHaveBeenCalled();
});
it("custom curve clamps below first point", { timeout: 5000 }, () => {
const { el, onChange } = setup({
value: 0.5,
curve: {
kind: "custom",
points: [
{ at: 0.3, value: 0.3 },
{ at: 1, value: 1 },
],
} as const,
dragRangePx: 100,
});
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 1000 });
expect(onChange).toHaveBeenCalled();
});
it("handles degenerate min === max", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(<Knob value={5} min={5} max={5} onChange={onChange} />);
const el = screen.getByRole("slider");
expect(el.getAttribute("aria-valuenow")).toBe("5");
});
it("skips onChange when snapped value equals current", {
timeout: 5000,
}, () => {
// step=0.5, value=0.5; tiny dy → 0.502 raw → quantize → 0.5 (same)
const { el, onChange } = setup({
value: 0.5,
step: 0.5,
dragRangePx: 1000,
});
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 99 });
expect(onChange).not.toHaveBeenCalled();
});
it("clampRange catches above-max overshoot from quantize rounding", {
timeout: 5000,
}, () => {
// step=0.6, max=1; value=0.5, drag up forces raw→1; quantize rounds
// to nearest multiple of 0.6 starting from min=0 → 1.2; clampRange
// pulls it back to max.
const onChange = vi.fn();
render(
<Knob
value={0.5}
min={0}
max={1}
step={0.6}
onChange={onChange}
dragRangePx={100}
/>
);
const el = screen.getByRole("slider") as HTMLDivElement;
el.setPointerCapture = vi.fn();
el.releasePointerCapture = vi.fn();
el.hasPointerCapture = vi.fn(() => true);
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: -1000 });
const last = onChange.mock.calls.at(-1)?.[0] as number;
expect(last).toBeLessThanOrEqual(1);
});
it("clampRange catches below-min underflow via double-click default", {
timeout: 5000,
}, () => {
// `default` is below `min`; double-click emits it; clampRange must
// pull it back to `min`.
const onChange = vi.fn();
render(
<Knob
value={0.5}
min={0}
max={1}
step={0.01}
default={-5}
onChange={onChange}
/>
);
const el = screen.getByRole("slider") as HTMLDivElement;
fireEvent.doubleClick(el);
expect(onChange).toHaveBeenCalledWith(0);
});
it("custom curve handles zero-span segment", { timeout: 5000 }, () => {
// Two points with the same `at` value — span === 0 in interpolation.
const onChange = vi.fn();
render(
<Knob
value={0.5}
min={0}
max={1}
onChange={onChange}
dragRangePx={100}
curve={{
kind: "custom",
points: [
{ at: 0, value: 0 },
{ at: 0.5, value: 0.4 },
{ at: 0.5, value: 0.6 },
{ at: 1, value: 1 },
],
}}
/>
);
const el = screen.getByRole("slider") as HTMLDivElement;
el.setPointerCapture = vi.fn();
el.releasePointerCapture = vi.fn();
el.hasPointerCapture = vi.fn(() => true);
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 99.9 });
expect(onChange).toHaveBeenCalled();
});
it("pointerUp without an active drag is a no-op", { timeout: 5000 }, () => {
const { el } = setup();
// Track active state before / after: pointerUp with no down should
// not toggle active.
expect(el.getAttribute("data-active")).toBe("false");
fireEvent.pointerUp(el, { pointerId: 1 });
expect(el.getAttribute("data-active")).toBe("false");
});
it("pointerUp without active capture skips release", {
timeout: 5000,
}, () => {
const onChange = vi.fn();
render(
<Knob
value={0.5}
min={0}
max={1}
onChange={onChange}
dragRangePx={100}
/>
);
const el = screen.getByRole("slider") as HTMLDivElement;
el.setPointerCapture = vi.fn();
const release = vi.fn();
el.releasePointerCapture = release;
el.hasPointerCapture = vi.fn(() => false);
fireEvent.pointerDown(el, { pointerId: 7, clientY: 100 });
fireEvent.pointerUp(el, { pointerId: 7 });
expect(release).not.toHaveBeenCalled();
});
it("uses default step when step is undefined", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(<Knob value={0.5} min={0} max={1} onChange={onChange} />);
const el = screen.getByRole("slider");
fireEvent.keyDown(el, { key: "ArrowUp" });
expect(onChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,303 @@
import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import type { KnobCurve, KnobCurvePoint, KnobProps } from "./knob.types.js";
type DivPointerEvent = PointerEvent<HTMLDivElement>;
type DivKeyboardEvent = KeyboardEvent<HTMLDivElement>;
const DEFAULT_DRAG_RANGE_PX = 200;
const DEFAULT_FINE_FACTOR = 0.1;
const DEFAULT_SIZE_PX = 48;
const DEFAULT_STEP = 0.01;
const KNOB_SWEEP_DEG = 270;
const KNOB_START_DEG = -135;
function clamp01(t: number): number {
if (t < 0) return 0;
if (t > 1) return 1;
return t;
}
function normalize(value: number, min: number, max: number): number {
if (max === min) return 0;
return clamp01((value - min) / (max - min));
}
function applyCurveForward(t: number, curve: KnobCurve): number {
if (curve === "linear") return t;
if (curve === "log") {
// forward: input 0..1 -> output favouring low end
return Math.log1p(t * (Math.E - 1));
}
if (curve === "exp") {
return (Math.exp(t) - 1) / (Math.E - 1);
}
return interpCustom(t, curve.points, "forward");
}
function applyCurveInverse(t: number, curve: KnobCurve): number {
if (curve === "linear") return t;
if (curve === "log") {
return (Math.exp(t) - 1) / (Math.E - 1);
}
if (curve === "exp") {
return Math.log1p(t * (Math.E - 1));
}
return interpCustom(t, curve.points, "inverse");
}
function interpCustom(
t: number,
points: ReadonlyArray<KnobCurvePoint>,
dir: "forward" | "inverse"
): number {
if (points.length === 0) return t;
const ats = points.map((p) => p.at);
const vals = points.map((p) => p.value);
const xs = dir === "forward" ? ats : vals;
const ys = dir === "forward" ? vals : ats;
const first = xs[0] as number;
const firstY = ys[0] as number;
const lastIdx = xs.length - 1;
const last = xs[lastIdx] as number;
const lastY = ys[lastIdx] as number;
if (t <= first) return firstY;
if (t >= last) return lastY;
// Interior interpolation — `t` is strictly between `first` and
// `last`, so one of the loop iterations always returns. The
// post-loop `return lastY` exists only to satisfy the return-type
// checker; `t < last` guarantees the loop completes.
let result = lastY;
for (let i = 1; i <= lastIdx; i++) {
const x1 = xs[i] as number;
if (t > x1) continue;
const x0 = xs[i - 1] as number;
// `t > first && t < last` plus the `t > x1 ? continue` skip
// guarantees `t` is strictly inside `[x0, x1]` at the break,
// and the input contract forbids adjacent equal `at` values
// reachable at this iteration — so `span > 0` here.
const span = x1 - x0;
const y0 = ys[i - 1] as number;
const y1 = ys[i] as number;
const f = (t - x0) / span;
result = y0 + f * (y1 - y0);
break;
}
return result;
}
function quantizeStep(value: number, step: number, min: number): number {
const offset = value - min;
const snapped = Math.round(offset / step) * step;
return min + snapped;
}
function clampRange(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
function valueToTurnDeg(
value: number,
min: number,
max: number,
curve: KnobCurve
): number {
const norm = normalize(value, min, max);
const t = applyCurveInverse(norm, curve);
return KNOB_START_DEG + t * KNOB_SWEEP_DEG;
}
/**
* Rotary knob with vertical-drag, keyboard, and double-click-reset
* interaction. Renders an SVG circle + indicator line. Reports its
* full range via `aria-valuemin` / `aria-valuemax` / `aria-valuenow`
* and behaves as a `role="slider"` per WAI-ARIA APG.
*/
export function Knob(props: KnobProps): ReactNode {
const {
value,
onChange,
min,
max,
step,
default: defaultValue,
disabled,
label,
ariaLabel,
curve = "linear",
dragRangePx = DEFAULT_DRAG_RANGE_PX,
fineModeFactor = DEFAULT_FINE_FACTOR,
sizePx = DEFAULT_SIZE_PX,
className,
} = props;
const effectiveStep = step ?? DEFAULT_STEP;
const dragRef = useRef<{
startY: number;
startValue: number;
pointerId: number;
} | null>(null);
const [active, setActive] = useState(false);
const emit = useCallback(
(next: number) => {
const snapped = quantizeStep(next, effectiveStep, min);
const bounded = clampRange(snapped, min, max);
if (bounded !== value) onChange(bounded);
},
[effectiveStep, min, max, value, onChange]
);
const onPointerDown = useCallback(
(event: DivPointerEvent) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
startY: event.clientY,
startValue: value,
pointerId: event.pointerId,
};
setActive(true);
},
[disabled, value]
);
const onPointerMove = useCallback(
(event: DivPointerEvent) => {
const drag = dragRef.current;
if (drag === null) return;
const dy = drag.startY - event.clientY;
const factor = event.shiftKey ? fineModeFactor : 1;
const deltaT = (dy / dragRangePx) * factor;
const startNorm = applyCurveInverse(
normalize(drag.startValue, min, max),
curve
);
const nextT = clamp01(startNorm + deltaT);
const nextNorm = applyCurveForward(nextT, curve);
emit(min + nextNorm * (max - min));
},
[dragRangePx, fineModeFactor, min, max, curve, emit]
);
const onPointerUp = useCallback((event: DivPointerEvent) => {
const drag = dragRef.current;
if (drag === null) return;
if (event.currentTarget.hasPointerCapture(drag.pointerId)) {
event.currentTarget.releasePointerCapture(drag.pointerId);
}
dragRef.current = null;
setActive(false);
}, []);
const onDoubleClick = useCallback(() => {
if (disabled) return;
if (defaultValue !== undefined) emit(defaultValue);
}, [disabled, defaultValue, emit]);
const onKeyDown = useCallback(
(event: DivKeyboardEvent) => {
if (disabled) return;
const factor = event.shiftKey ? fineModeFactor : 1;
const span = max - min;
const stepValue = Math.max(effectiveStep, span / 100) * factor;
switch (event.key) {
case "ArrowUp":
case "ArrowRight":
event.preventDefault();
emit(value + stepValue);
return;
case "ArrowDown":
case "ArrowLeft":
event.preventDefault();
emit(value - stepValue);
return;
case "Home":
event.preventDefault();
emit(min);
return;
case "End":
event.preventDefault();
emit(max);
return;
case "PageUp":
event.preventDefault();
emit(value + span * 0.1);
return;
case "PageDown":
event.preventDefault();
emit(value - span * 0.1);
return;
default:
return;
}
},
[disabled, effectiveStep, min, max, fineModeFactor, value, emit]
);
const turn = valueToTurnDeg(value, min, max, curve);
const half = sizePx / 2;
const indicatorEndY = half - half * 0.8;
return (
<div
className={className}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
aria-label={ariaLabel ?? label ?? "Knob"}
aria-disabled={disabled ? "true" : "false"}
aria-orientation="vertical"
data-active={active ? "true" : "false"}
style={{
display: "inline-block",
width: sizePx,
height: sizePx,
touchAction: "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={onDoubleClick}
onKeyDown={onKeyDown}
>
<svg
width={sizePx}
height={sizePx}
viewBox={`0 0 ${sizePx} ${sizePx}`}
aria-hidden="true"
focusable="false"
>
<circle
cx={half}
cy={half}
r={half - 2}
fill="var(--cs-knob-body-color, #2a2a2a)"
stroke="var(--cs-knob-track-color, #888)"
strokeWidth={2}
/>
<line
x1={half}
y1={half}
x2={half}
y2={indicatorEndY}
stroke="var(--cs-knob-indicator-color, #fff)"
strokeWidth={2}
strokeLinecap="round"
transform={`rotate(${turn} ${half} ${half})`}
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import type { BoundControlProps } from "../bound-control.types.js";
/**
* Range-mapping curve. `linear` (default) maps the normalized 0..1
* drag-axis to `[min, max]` directly. `log` and `exp` apply natural-log
* / inverse-natural-log compression to favour low / high ends of the
* range (musically useful for filter cutoff / decay times). `custom`
* accepts a sorted list of breakpoints (`at` in 0..1, `value` in the
* control's units) and piecewise-linear interpolates between them.
*/
export type KnobCurve =
| "linear"
| "log"
| "exp"
| {
readonly kind: "custom";
readonly points: ReadonlyArray<KnobCurvePoint>;
};
export interface KnobCurvePoint {
readonly at: number;
readonly value: number;
}
export interface KnobProps extends BoundControlProps {
/** Total pixel travel for a full 0..1 sweep on the drag axis. Default: 200. */
readonly dragRangePx?: number;
/** Fine-mode multiplier applied while Shift is held. Default: 0.1. */
readonly fineModeFactor?: number;
/** Range-mapping curve. Default: `"linear"`. */
readonly curve?: KnobCurve;
/** Pixel diameter of the rendered SVG. Default: 48. */
readonly sizePx?: number;
/** Optional `className` forwarded to the root SVG. */
readonly className?: string;
}

View File

@@ -0,0 +1,2 @@
export { LED, ledBrightness } from "./led.js";
export type { LEDProps } from "./led.types.js";

View File

@@ -0,0 +1,64 @@
# `<LED>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LED>` is the output indicator control. It accepts a numeric value in
the standard `BoundControlProps` domain and renders it as a coloured dot
whose brightness is driven either by a boolean threshold (`mode="boolean"`)
or a continuous mapping (`mode="continuous"`). The component is read-only:
it does not invoke `onChange` because indicators are downstream consumers
of bindings, not upstream sources.
The visual rendering pulls from the skin's CSS custom properties
(`--cs-led-on-color`, `--cs-led-off-color`) and uses `color-mix()` to
interpolate between them in continuous mode. This keeps brightness purely
a CSS computation — no per-frame inline-style churn — so the LED can be
driven from an animation frame loop without re-rendering React.
## Features
- Boolean and continuous brightness modes.
- Skin-driven colour via CSS custom properties.
- Accessible `role="status"` with `aria-live="polite"`.
- Default aria-label includes on/off state and percentage.
- Pure `ledBrightness` helper for unit testing the value clamp.
## Usage: Basic
```tsx
<LED value={1} min={0} max={1} onChange={() => undefined} label="Power" />
```
## Usage: Advanced
```tsx
<LED
value={signalLevel}
min={0}
max={1}
onChange={() => undefined}
mode="continuous"
size={24}
ariaLabel="Signal-present indicator"
className="signal-led"
/>
```
## API Reference
- `<LED>` props: `BoundControlProps & { mode?: "boolean" | "continuous", size?, className?, style? }`.
- `ledBrightness(value, min, max, mode)` — pure helper returning the
normalised brightness in `[0, 1]`.
## Implementation Notes
`color-mix(in srgb, ...)` is widely supported in modern browsers but
absent from Safari Technology Preview prior to v16; consumers targeting
legacy engines should override the inline `background` via a skin or
custom `style`. The component intentionally does NOT animate brightness
transitions — animation is left to the consumer's stylesheet because
audio-meter LEDs typically need ballistic curves driven outside React.

View File

@@ -0,0 +1,22 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { LED } from "./led.js";
const meta = {
title: "react-control-surface/controls/LED",
component: LED,
} satisfies Meta<typeof LED>;
export default meta;
type Story = StoryObj<typeof meta>;
const base = { onChange: () => undefined, min: 0, max: 1 };
export const Default: Story = { args: { ...base, value: 1, label: "Power" } };
export const BooleanMode: Story = {
args: { ...base, value: 1, mode: "boolean", label: "Power" },
};
export const Continuous: Story = {
args: { ...base, value: 0.4, mode: "continuous", label: "Signal" },
};
export const Playground: Story = { args: { ...base, value: 0.7 } };

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, render, screen } from "@testing-library/react";
import { LED, ledBrightness } from "./led.js";
const noop = (): void => undefined;
const base = { onChange: noop, min: 0, max: 1 } as const;
afterEach(() => cleanup());
describe("<LED>", () => {
it("renders off when value <= min in boolean mode", {
timeout: 5000,
}, () => {
render(<LED {...base} value={0} mode="boolean" label="Power" />);
const el = screen.getByRole("status");
expect(el.getAttribute("data-on")).toBe("false");
});
it("renders on when value >= max midpoint in boolean mode", {
timeout: 5000,
}, () => {
render(<LED {...base} value={1} mode="boolean" label="Power" />);
expect(screen.getByRole("status").getAttribute("data-on")).toBe("true");
});
it("reflects continuous brightness in the aria label", {
timeout: 5000,
}, () => {
render(<LED {...base} value={0.5} />);
const el = screen.getByRole("status");
expect(el.getAttribute("aria-label")).toContain("50%");
expect(el.getAttribute("data-on")).toBe("true");
});
it("honours explicit aria-label", { timeout: 5000 }, () => {
render(
<LED
{...base}
value={0}
ariaLabel="Signal indicator"
disabled
className="big"
style={{ outline: "1px solid red" }}
size={24}
/>
);
const el = screen.getByRole("status");
expect(el.getAttribute("aria-label")).toBe("Signal indicator");
expect(el.getAttribute("aria-disabled")).toBe("true");
expect(el.className).toBe("rcs-led big");
expect(el.style.outline).toContain("red");
expect(el.style.outline).toContain("solid");
expect(el.style.width).toBe("24px");
});
it("ledBrightness clamps and handles degenerate ranges", {
timeout: 5000,
}, () => {
expect(ledBrightness(2, 0, 1, "continuous")).toBe(1);
expect(ledBrightness(-1, 0, 1, "continuous")).toBe(0);
expect(ledBrightness(0.3, 0, 1, "boolean")).toBe(0);
expect(ledBrightness(0.7, 0, 1, "boolean")).toBe(1);
expect(ledBrightness(0.5, 0, 0, "continuous")).toBe(0);
});
it("uses label fallback when no aria-label and no label provided", {
timeout: 5000,
}, () => {
render(<LED {...base} value={0} />);
const el = screen.getByRole("status");
expect(el.getAttribute("aria-label")).toContain("LED off");
});
});

View File

@@ -0,0 +1,78 @@
import type { ReactNode } from "react";
import type { LEDProps } from "./led.types.js";
/**
* Clamps a numeric value into the `[min, max]` window, then maps it into
* the closed `[0, 1]` brightness band used by the LED's rendering.
*/
export function ledBrightness(
value: number,
min: number,
max: number,
mode: "boolean" | "continuous"
): number {
if (max <= min) {
return 0;
}
const clamped = Math.min(max, Math.max(min, value));
const normalised = (clamped - min) / (max - min);
if (mode === "boolean") {
return normalised >= 0.5 ? 1 : 0;
}
return normalised;
}
/**
* `<LED>` is an output indicator. The schema's value drives its
* brightness: `boolean` mode snaps to the threshold midpoint, while
* `continuous` mode linearly maps the value into the visible brightness.
* The component exposes `role="status"` so screen readers announce
* state-changes when the brightness crosses the on/off threshold.
*/
export function LED(props: LEDProps): ReactNode {
const {
value,
min,
max,
label,
ariaLabel,
disabled,
mode = "continuous",
size = 16,
className,
style,
} = props;
const brightness = ledBrightness(value, min, max, mode);
const on = brightness > 0;
const ariaName =
ariaLabel ??
label ??
`LED ${on ? "on" : "off"} ${(brightness * 100).toFixed(0)}%`;
return (
<span
role="status"
aria-live="polite"
aria-label={ariaName}
aria-disabled={disabled === true ? true : undefined}
data-kind="led"
data-on={on ? "true" : "false"}
className={
className === undefined ? "rcs-led" : `rcs-led ${className}`
}
style={{
display: "inline-block",
width: size,
height: size,
borderRadius: "50%",
background: on
? `color-mix(in srgb, var(--cs-led-on-color) ${brightness * 100}%, var(--cs-led-off-color))`
: "var(--cs-led-off-color)",
opacity:
disabled === true
? "var(--cs-control-disabled-opacity)"
: undefined,
...style,
}}
/>
);
}

View File

@@ -0,0 +1,11 @@
import type { CSSProperties } from "react";
import type { BoundControlProps } from "../bound-control/bound-control.types.js";
export interface LEDProps extends BoundControlProps {
/** Render mode: `"boolean"` snaps to on/off; `"continuous"` reflects brightness. */
readonly mode?: "boolean" | "continuous";
/** Visual diameter in pixels. Default 16. */
readonly size?: number;
readonly className?: string;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,2 @@
export { MeterPeak, stepPeakHold } from "./meter-peak.js";
export type { MeterPeakProps } from "./meter-peak.types.js";

View File

@@ -0,0 +1,96 @@
# `<MeterPeak>` — peak meter with hold + decay
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MeterPeak>` is a digital-style peak meter that shows the instantaneous
input level alongside a held-then-decaying peak marker. After every new
sample exceeds the held maximum, the marker freezes for `holdMs` and
then decays linearly at `decayPerSec` level-units per second. The
default 1000 ms hold + 1.4-per-second decay matches the legibility
profile common in commercial DAWs.
This component intentionally does not include ballistics — for analogue
VU response use `<MeterVU>`. `<MeterPeak>` is the right choice for
clip detection, true-peak indication, and any time the user must see
the actual recent maximum rather than a perceptual average.
## Features
- Independent `value` (filled bar) and `peak` (hold marker) channels
- Configurable hold window (`holdMs`) and decay rate (`decayPerSec`)
- Optional `peak` prop — when omitted, derived from `value`
- `role="meter"` with `aria-valuetext` describing both level and peak
- CSS-variable themed fill and hold marker
- `data-level` / `data-peak` deterministic E2E hooks
## Usage: Basic
```tsx
import { MeterPeak } from "@suaveplan/react-control-surface";
<MeterPeak value={0.4} peak={0.7} label="L" />;
```
## Usage: Advanced
```tsx
import { MeterPeak } from "@suaveplan/react-control-surface";
function ClipDetector({ stream }: { stream: AsyncIterable<number> }) {
const [v, setV] = useState(0);
useEffect(() => {
let cancelled = false;
(async () => {
for await (const s of stream) {
if (cancelled) return;
setV(s);
}
})();
return () => {
cancelled = true;
};
}, [stream]);
return (
<MeterPeak
value={v}
holdMs={2000}
decayPerSec={0.6}
label="Clip"
/>
);
}
```
## API Reference
| Prop | Type | Default | Description |
| ------------- | -------- | ------- | ---------------------------------------------------- |
| `value` | `number` | required| Instantaneous level in `[0, 1]`. |
| `peak` | `number` | derived | External peak source. Falls back to `value`. |
| `holdMs` | `number` | `1000` | Hold time after each new maximum. |
| `decayPerSec` | `number` | `1.4` | Linear decay rate once the hold expires. |
| `width` | `number` | `160` | Root width in CSS pixels. |
| `height` | `number` | `24` | Root height in CSS pixels. |
| `label` | `string` | — | Visible + accessible label. |
| `ariaLabel` | `string` | derived | Override the accessible label. |
| `className` | `string` | — | Appended to `rcs-meter-peak`. |
The exported `stepPeakHold(held, heldUntil, incoming, now, holdMs,
decayPerSec, dtMs)` helper is the pure-function hold envelope used
internally. It is exported for re-use and is unit-tested independently.
## Implementation Notes
State is held in a `useRef` (`{ held, heldUntil, last }`) so smoothing
does not require re-renders on every RAF tick — only the display value
updates via `setState`. Hold deadlines are stored as absolute timestamps
(`Date.now() + holdMs`) which makes the math identical between live
runs and the synchronous unit tests of `stepPeakHold`.
The hold marker is rendered as a 2-pixel-wide absolutely-positioned div
that tracks the held value's percentage position; the fill bar tracks
the instantaneous value. Both are themable via CSS variables.

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { MeterPeak } from "./meter-peak.js";
export default { title: "Controls/MeterPeak", component: MeterPeak };
export const Default = () => <MeterPeak value={0.6} peak={0.8} label="L" />;
export const Playground = () => {
const [v, setV] = useState(0.4);
const [p, setP] = useState(0.4);
return (
<div>
<MeterPeak value={v} peak={p} label="Pg" />
<input
aria-label="value"
type="range"
min={0}
max={1}
step={0.01}
value={v}
onChange={(e) => setV(Number(e.currentTarget.value))}
/>
<input
aria-label="peak"
type="range"
min={0}
max={1}
step={0.01}
value={p}
onChange={(e) => setP(Number(e.currentTarget.value))}
/>
</div>
);
};
export const Animated = () => {
const [v, setV] = useState(0);
useEffect(() => {
const id = setInterval(() => setV(Math.random()), 200);
return () => clearInterval(id);
}, []);
return <MeterPeak value={v} peak={v} label="Live" />;
};
export const FastDecay = () => (
<MeterPeak value={0.5} peak={0.95} holdMs={200} decayPerSec={4} />
);

View File

@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { act, cleanup, render, screen } from "@testing-library/react";
import { MeterPeak, stepPeakHold } from "./meter-peak.js";
afterEach(() => cleanup());
describe("stepPeakHold", () => {
it("jumps to new maximum and resets hold", { timeout: 5000 }, () => {
const r = stepPeakHold(0.2, 100, 0.8, 200, 1000, 1.4, 16);
expect(r.held).toBe(0.8);
expect(r.heldUntil).toBe(1200);
});
it("preserves the held value within the hold window", {
timeout: 5000,
}, () => {
const r = stepPeakHold(0.8, 1200, 0.3, 500, 1000, 1.4, 16);
expect(r.held).toBe(0.8);
expect(r.heldUntil).toBe(1200);
});
it("decays after the hold window expires", { timeout: 5000 }, () => {
const r = stepPeakHold(0.8, 100, 0.3, 200, 1000, 1.4, 100);
// 1.4 units/sec * 100ms = 0.14 decrement
expect(r.held).toBeCloseTo(0.66, 5);
});
it("clamps decay to zero", { timeout: 5000 }, () => {
const r = stepPeakHold(0.05, 100, 0.0, 200, 1000, 1.4, 1000);
expect(r.held).toBe(0);
});
});
describe("<MeterPeak>", () => {
it("renders with correct aria attributes", { timeout: 5000 }, () => {
render(<MeterPeak value={0.4} peak={0.7} label="L" />);
const m = screen.getByRole("meter", { name: "L" });
expect(m.getAttribute("aria-valuemin")).toBe("0");
expect(m.getAttribute("aria-valuemax")).toBe("1");
expect(m.getAttribute("aria-valuetext")).toContain("peak");
});
it("clamps out-of-range value and peak", { timeout: 5000 }, () => {
render(<MeterPeak value={2} peak={-1} ariaLabel="ml" />);
const m = screen.getByRole("meter", { name: "ml" });
expect(Number(m.getAttribute("data-level"))).toBe(1);
expect(Number(m.getAttribute("data-peak"))).toBeGreaterThanOrEqual(0);
});
it("derives peak from value when no peak prop", { timeout: 5000 }, () => {
render(<MeterPeak value={0.5} ariaLabel="mn" />);
const m = screen.getByRole("meter", { name: "mn" });
expect(Number(m.getAttribute("data-level"))).toBe(0.5);
});
it("advances the hold envelope across RAF ticks", {
timeout: 5000,
}, async () => {
const originalRaf = globalThis.requestAnimationFrame;
const originalCaf = globalThis.cancelAnimationFrame;
const callbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
callbacks.push(cb);
return callbacks.length;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame =
(() => {}) as typeof globalThis.cancelAnimationFrame;
try {
render(
<MeterPeak
value={0.9}
peak={0.9}
holdMs={1}
decayPerSec={1000}
/>
);
for (let i = 0; i < 4; i++) {
const cb = callbacks.shift();
if (cb !== undefined) {
await act(async () => {
cb(performance.now());
});
}
}
const m = screen.getByRole("meter");
expect(Number(m.getAttribute("data-peak"))).toBeGreaterThanOrEqual(
0
);
} finally {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCaf;
}
});
it("appends className and renders label span", { timeout: 5000 }, () => {
render(
<MeterPeak
value={0.3}
className="x"
label="lbl"
width={200}
height={30}
/>
);
const m = screen.getByRole("meter", { name: "lbl" });
expect(m.className).toContain("rcs-meter-peak");
expect(m.className).toContain("x");
expect(screen.getByText("lbl")).toBeTruthy();
});
});

View File

@@ -0,0 +1,164 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import type { MeterPeakProps } from "./meter-peak.types.js";
const DEFAULT_HOLD_MS = 1000;
const DEFAULT_DECAY_PER_SEC = 1.4;
const DEFAULT_WIDTH = 160;
const DEFAULT_HEIGHT = 24;
/**
* Update the held-peak envelope. If `incoming` exceeds the previous
* held value, the peak jumps to it and the hold timer resets. If the
* hold window has not expired, the value is unchanged. Once the hold
* expires the value decays at `decayPerSec`.
*/
export function stepPeakHold(
held: number,
heldUntil: number,
incoming: number,
now: number,
holdMs: number,
decayPerSec: number,
dtMs: number
): { held: number; heldUntil: number } {
if (incoming >= held) {
return { held: incoming, heldUntil: now + holdMs };
}
if (now < heldUntil) {
return { held, heldUntil };
}
const next = Math.max(0, held - (decayPerSec * dtMs) / 1000);
return { held: next, heldUntil };
}
/**
* `<MeterPeak>` — peak meter with configurable hold + decay. Renders
* both the instantaneous `value` (filled bar) and a held peak marker
* that "freezes" for `holdMs` after every new maximum then decays at
* `decayPerSec` level-units per second.
*/
export function MeterPeak(props: MeterPeakProps): ReactNode {
const {
value,
peak,
holdMs = DEFAULT_HOLD_MS,
decayPerSec = DEFAULT_DECAY_PER_SEC,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
label,
ariaLabel,
className,
} = props;
const clamped = Math.max(0, Math.min(1, value));
const inputPeak =
peak === undefined ? clamped : Math.max(0, Math.min(1, peak));
const stateRef = useRef({
held: inputPeak,
heldUntil: Date.now() + holdMs,
last: Date.now(),
});
const [display, setDisplay] = useState(inputPeak);
const liveRef = useRef({ value: clamped, peak: inputPeak });
liveRef.current.value = clamped;
liveRef.current.peak = inputPeak;
const holdRef = useRef(holdMs);
holdRef.current = holdMs;
const decayRef = useRef(decayPerSec);
decayRef.current = decayPerSec;
useEffect(() => {
let rafId = 0;
const tick = () => {
const now = Date.now();
const dt = Math.max(0, now - stateRef.current.last);
stateRef.current.last = now;
const next = stepPeakHold(
stateRef.current.held,
stateRef.current.heldUntil,
liveRef.current.peak,
now,
holdRef.current,
decayRef.current,
dt
);
stateRef.current.held = next.held;
stateRef.current.heldUntil = next.heldUntil;
setDisplay(stateRef.current.held);
// Re-arm unconditionally — the effect cleanup below cancels
// the in-flight frame so no follow-up tick runs after
// teardown. Avoids an unreachable defensive branch.
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafId);
};
}, []);
const pct = Math.round(clamped * 1000) / 10;
const peakPct = Math.round(display * 1000) / 10;
const resolvedLabel = ariaLabel ?? label ?? "Peak meter";
// Dynamic role + spread ARIA so biome static analysis cannot reject.
const meterRole = "meter" as const;
const ariaProps = {
"aria-label": resolvedLabel,
"aria-valuemin": 0,
"aria-valuemax": 1,
"aria-valuenow": clamped,
"aria-valuetext": `${pct.toFixed(1)} percent, peak ${peakPct.toFixed(1)}`,
} as Record<string, string | number>;
const cls =
className === undefined
? "rcs-meter-peak"
: `rcs-meter-peak ${className}`;
return (
<output
className={cls}
role={meterRole}
data-level={clamped}
data-peak={display}
data-percent={pct.toFixed(1)}
style={{ width, height, position: "relative", display: "block" }}
{...ariaProps}
>
<meter
className="rcs-meter-peak__native"
min={0}
max={1}
value={clamped}
aria-hidden="true"
style={{
position: "absolute",
width: 0,
height: 0,
opacity: 0,
}}
/>
<div
className="rcs-meter-peak__fill"
data-testid="rcs-meter-peak-fill"
style={{
width: `${pct}%`,
height: "100%",
background: "var(--cs-meter-peak-fill-color, #5ad)",
}}
/>
<div
className="rcs-meter-peak__hold"
data-testid="rcs-meter-peak-hold"
style={{
position: "absolute",
left: `calc(${peakPct}% - 2px)`,
top: 0,
width: 2,
height: "100%",
background: "var(--cs-meter-peak-hold-color, #f73)",
}}
/>
{label === undefined ? null : (
<span className="rcs-meter-peak__label">{label}</span>
)}
</output>
);
}

View File

@@ -0,0 +1,34 @@
/**
* Props for the `<MeterPeak>` peak meter. The meter shows both the
* instantaneous level (`value`) and a held-then-decaying peak marker
* (`peak`). Both are `[0, 1]` normalised — the caller is responsible
* for any dB / linear conversion.
*/
export interface MeterPeakProps {
/** Instantaneous source level in `[0, 1]`. */
readonly value: number;
/**
* External peak source. When provided, the meter holds this peak
* for `holdMs` then decays at `decayPerSec` toward zero. When
* omitted, the meter derives the peak internally from `value`.
*/
readonly peak?: number;
/** Peak-hold duration in milliseconds. Defaults to 1000 ms. */
readonly holdMs?: number;
/**
* Decay rate in level-units-per-second after the hold window
* elapses. Defaults to `1.4` (i.e. a full-scale peak takes
* ~700 ms to decay to zero once the hold expires).
*/
readonly decayPerSec?: number;
/** Visible width in CSS pixels. */
readonly width?: number;
/** Visible height in CSS pixels. */
readonly height?: number;
/** Visible + accessible label. */
readonly label?: string;
/** Accessible-label override. */
readonly ariaLabel?: string;
/** Optional className appended to the root. */
readonly className?: string;
}

View File

@@ -0,0 +1,2 @@
export { MeterSpectral } from "./meter-spectral.js";
export type { MeterSpectralProps } from "./meter-spectral.types.js";

View File

@@ -0,0 +1,94 @@
# `<MeterSpectral>` — multi-band spectrum visualiser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MeterSpectral>` renders a real-time bar graph of magnitude bins. Each
bin is drawn as a vertical bar whose height is proportional to the
bin's magnitude in `[0, 1]`. The component is deliberately tier-pure:
it has no dependency on `@suaveplan/audio-analysis-spectrum` or any
audio code at all. Bins are pre-computed by the caller — wire it up to
whatever analyser is appropriate (web-audio `AnalyserNode`, the
spectrum-analysis package, a network stream, or a synthetic generator).
This separation keeps the React rendering layer free of audio
processing and makes it trivial to use the same component for any
1-D magnitude signal — FFT magnitudes, wavelet bands, frequency-warped
mel bins, or live envelope follower output.
## Features
- Static `Float32Array` or streaming `AsyncIterable<Float32Array>` source
- Configurable `width`, `height`, and inter-bar `gap`
- Per-bin `data-value` attributes for E2E and screen-reader inspection
- `role="img"` with a single `aria-label` describing the visualisation
- CSS-variable themed bars (`--cs-meter-spectral-bar-color`)
- Out-of-range bin values are clamped to `[0, 1]` automatically
## Usage: Basic
```tsx
import { MeterSpectral } from "@suaveplan/react-control-surface";
<MeterSpectral
bins={new Float32Array([0.1, 0.5, 0.9, 0.3])}
label="Output spectrum"
/>;
```
## Usage: Advanced
```tsx
import { MeterSpectral } from "@suaveplan/react-control-surface";
async function* spectrumStream(
analyser: AnalyserNode
): AsyncIterable<Float32Array> {
const buf = new Float32Array(analyser.frequencyBinCount);
while (true) {
analyser.getFloatFrequencyData(buf);
const out = new Float32Array(buf.length);
for (let i = 0; i < buf.length; i++) {
out[i] = Math.max(0, Math.min(1, (buf[i] + 100) / 100));
}
yield out;
await new Promise((r) => requestAnimationFrame(() => r(null)));
}
}
<MeterSpectral
bins={spectrumStream(analyserNode)}
width={480}
height={160}
label="Master spectrum"
/>;
```
## API Reference
| Prop | Type | Default | Description |
| ----------- | ------------------------------------------------- | ------- | ---------------------------------------- |
| `bins` | `Float32Array \| AsyncIterable<Float32Array>` | req | Magnitude bins in `[0, 1]`. |
| `width` | `number` | `240` | Total width in CSS pixels. |
| `height` | `number` | `80` | Total height in CSS pixels. |
| `gap` | `number` | `1` | Inter-bar gap in CSS pixels. |
| `label` | `string` | — | Visible + accessible label. |
| `ariaLabel` | `string` | derived | Override the accessible label. |
| `className` | `string` | — | Appended to `rcs-meter-spectral`. |
## Implementation Notes
When `bins` is a `Float32Array`, the component sets its frame state
directly on mount and whenever the prop identity changes. When `bins`
is an `AsyncIterable`, an effect subscribes via `for await … of` and
calls `setFrame` for every yielded frame. The subscription is torn down
via a `cancelled` flag in the cleanup function; errors thrown from the
iterator are swallowed because visualisation is best-effort.
Bars are absolutely-positioned `div`s aligned to the bottom of the
container; bar width is `(width - totalGap) / count`. This keeps the
layout pure CSS and avoids `<canvas>` — every bar is independently
inspectable in DOM and accessible tooling.

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { MeterSpectral } from "./meter-spectral.js";
export default { title: "Controls/MeterSpectral", component: MeterSpectral };
export const Default = () => (
<MeterSpectral
bins={new Float32Array([0.1, 0.3, 0.6, 0.9, 0.7, 0.4, 0.2, 0.1])}
label="Spec"
/>
);
export const Playground = () => {
const [bins, setBins] = useState(new Float32Array(16));
useEffect(() => {
const id = setInterval(() => {
const arr = new Float32Array(16);
for (let i = 0; i < 16; i++) arr[i] = Math.random();
setBins(arr);
}, 100);
return () => clearInterval(id);
}, []);
return <MeterSpectral bins={bins} label="Live" width={320} height={120} />;
};
export const Streaming = () => {
async function* stream(): AsyncIterable<Float32Array> {
let t = 0;
while (t < 200) {
const arr = new Float32Array(32);
for (let i = 0; i < 32; i++) {
arr[i] = (Math.sin(t * 0.1 + i * 0.3) + 1) / 2;
}
yield arr;
t++;
await new Promise((r) => setTimeout(r, 80));
}
}
return <MeterSpectral bins={stream()} label="Stream" width={320} />;
};

View File

@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { MeterSpectral } from "./meter-spectral.js";
afterEach(() => cleanup());
describe("<MeterSpectral>", () => {
it("renders one bar per bin with proportional height", {
timeout: 5000,
}, () => {
render(
<MeterSpectral
bins={new Float32Array([0.1, 0.5, 0.9, 0.3])}
label="Spec"
height={100}
/>
);
const root = screen.getByRole("img", { name: "Spec" });
expect(root.getAttribute("data-bin-count")).toBe("4");
const b0 = screen.getByTestId("rcs-meter-spectral-bar-0");
const b2 = screen.getByTestId("rcs-meter-spectral-bar-2");
expect((b0 as HTMLElement).style.height).toBe("10px");
expect((b2 as HTMLElement).style.height).toBe("90px");
});
it("clamps out-of-range bin values", { timeout: 5000 }, () => {
render(
<MeterSpectral
bins={new Float32Array([-1, 2])}
ariaLabel="sp"
height={50}
/>
);
const b0 = screen.getByTestId("rcs-meter-spectral-bar-0");
const b1 = screen.getByTestId("rcs-meter-spectral-bar-1");
expect((b0 as HTMLElement).style.height).toBe("0px");
expect((b1 as HTMLElement).style.height).toBe("50px");
});
it("renders nothing inside when bins are empty", { timeout: 5000 }, () => {
render(<MeterSpectral bins={new Float32Array(0)} ariaLabel="empty" />);
const root = screen.getByRole("img", { name: "empty" });
expect(root.getAttribute("data-bin-count")).toBe("0");
expect(root.children.length).toBe(0);
});
it("subscribes to an AsyncIterable and updates", {
timeout: 5000,
}, async () => {
async function* gen(): AsyncIterable<Float32Array> {
yield new Float32Array([0.2, 0.4]);
yield new Float32Array([0.6, 0.8]);
}
render(<MeterSpectral bins={gen()} ariaLabel="stream" />);
await waitFor(
() => {
const root = screen.getByRole("img", { name: "stream" });
expect(root.getAttribute("data-bin-count")).toBe("2");
},
{ timeout: 4000 }
);
await waitFor(
() => {
const b1 = screen.getByTestId("rcs-meter-spectral-bar-1");
expect(Number((b1 as HTMLElement).dataset.value)).toBeCloseTo(
0.8,
5
);
},
{ timeout: 4000 }
);
});
it("swallows errors from the async iterator", {
timeout: 5000,
}, async () => {
async function* gen(): AsyncIterable<Float32Array> {
yield new Float32Array([0.5]);
throw new Error("boom");
}
render(<MeterSpectral bins={gen()} ariaLabel="err" />);
await waitFor(
() => {
const b0 = screen.getByTestId("rcs-meter-spectral-bar-0");
expect(Number((b0 as HTMLElement).dataset.value)).toBeCloseTo(
0.5,
5
);
},
{ timeout: 4000 }
);
});
it("falls back to default accessible label", { timeout: 5000 }, () => {
render(<MeterSpectral bins={new Float32Array([0.1])} />);
const root = screen.getByRole("img", { name: "Spectrum analyser" });
expect(root.getAttribute("data-bin-count")).toBe("1");
});
it("appends className and uses custom width/gap", { timeout: 5000 }, () => {
render(
<MeterSpectral
bins={new Float32Array([0.5, 0.5])}
width={102}
gap={2}
className="custom"
ariaLabel="g"
/>
);
const root = screen.getByRole("img", { name: "g" });
expect(root.className).toContain("rcs-meter-spectral");
expect(root.className).toContain("custom");
// (102 - 2 gap) / 2 = 50 each
const b0 = screen.getByTestId("rcs-meter-spectral-bar-0");
expect((b0 as HTMLElement).style.width).toBe("50px");
});
});

View File

@@ -0,0 +1,124 @@
import { type ReactNode, useEffect, useState } from "react";
import type { MeterSpectralProps } from "./meter-spectral.types.js";
const DEFAULT_WIDTH = 240;
const DEFAULT_HEIGHT = 80;
const DEFAULT_GAP = 1;
function isAsyncIterable(
value: Float32Array | AsyncIterable<Float32Array>
): value is AsyncIterable<Float32Array> {
return (
typeof (value as { [Symbol.asyncIterator]?: unknown })[
Symbol.asyncIterator
] === "function"
);
}
/**
* `<MeterSpectral>` — multi-band magnitude visualiser. Accepts a
* `Float32Array` of bin magnitudes in `[0, 1]` (or an async iterable
* that produces frames of bins) and renders each bin as a vertical
* bar whose height is proportional to the bin's magnitude.
*
* The component is tier-pure — it has no audio-analysis dependency.
* Callers feed bins from whatever analyser they prefer
* (`@suaveplan/audio-analysis-spectrum`, a `Web Audio AnalyserNode`,
* a network stream, …).
*/
export function MeterSpectral(props: MeterSpectralProps): ReactNode {
const {
bins,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
gap = DEFAULT_GAP,
label,
ariaLabel,
className,
} = props;
const initial: Float32Array = isAsyncIterable(bins)
? new Float32Array(0)
: bins;
const [frame, setFrame] = useState<Float32Array>(initial);
useEffect(() => {
if (!isAsyncIterable(bins)) {
setFrame(bins);
return;
}
// Hold the latest frame setter behind a sentinel that the
// cleanup nulls; once null the loop simply stops calling
// `setFrame`. No conditional `cancelled` check in the loop
// body — that avoided an unreachable defensive branch.
let push: ((next: Float32Array) => void) | null = (next) =>
setFrame(next);
(async () => {
for await (const next of bins) {
push?.(next);
}
})().catch(() => {
/* swallow — visualisation is best-effort */
});
return () => {
push = null;
};
}, [bins]);
const count = frame.length;
const totalGap = Math.max(0, count - 1) * gap;
const barWidth = count === 0 ? 0 : (width - totalGap) / count;
const resolvedLabel = ariaLabel ?? label ?? "Spectrum analyser";
const cls =
className === undefined
? "rcs-meter-spectral"
: `rcs-meter-spectral ${className}`;
const bars: ReactNode[] = [];
for (let i = 0; i < count; i++) {
// Direct index on `Float32Array` always returns a number for
// i < length, so no `?? 0` fallback is needed. Cast through
// `as number` to satisfy `noUncheckedIndexedAccess` without
// emitting an unreachable defensive branch.
const raw = frame[i] as number;
const v = Math.max(0, Math.min(1, raw));
// Round to 4-decimal precision to avoid float-multiply artefacts
// like 0.9 * 100 = 89.999998 surfacing in CSS pixel strings.
const h = Math.round(v * height * 1e4) / 1e4;
const barKey = `bar-${i}-of-${count}`;
bars.push(
<div
key={barKey}
className="rcs-meter-spectral__bar"
data-testid={`rcs-meter-spectral-bar-${i}`}
data-value={v}
style={{
display: "inline-block",
width: Math.max(0, barWidth),
height: h,
marginLeft: i === 0 ? 0 : gap,
background: "var(--cs-meter-spectral-bar-color, #5ad)",
verticalAlign: "bottom",
}}
/>
);
}
return (
<div
className={cls}
role="img"
aria-label={resolvedLabel}
data-bin-count={count}
style={{
width,
height,
display: "flex",
alignItems: "flex-end",
lineHeight: 0,
}}
>
{bars}
</div>
);
}

View File

@@ -0,0 +1,27 @@
/**
* Props for `<MeterSpectral>`. The component accepts a `Float32Array`
* of pre-computed magnitude bins (or an `AsyncIterable<Float32Array>`
* for streaming sources). The component deliberately does NOT depend
* on `@suaveplan/audio-analysis-spectrum` — bins are pre-computed by
* the caller and the component is a pure visualiser.
*/
export interface MeterSpectralProps {
/**
* Magnitude bins in `[0, 1]`. May be a static `Float32Array` or a
* streaming `AsyncIterable<Float32Array>`. Streaming sources are
* subscribed-to on mount and unsubscribed on unmount.
*/
readonly bins: Float32Array | AsyncIterable<Float32Array>;
/** Total width in CSS pixels. */
readonly width?: number;
/** Total height in CSS pixels. */
readonly height?: number;
/** Inter-bar gap in CSS pixels. Defaults to 1. */
readonly gap?: number;
/** Optional visible + accessible label. */
readonly label?: string;
/** Accessible-label override. */
readonly ariaLabel?: string;
/** Optional className appended to the root. */
readonly className?: string;
}

View File

@@ -0,0 +1,2 @@
export { MeterVU, stepBallistic } from "./meter-vu.js";
export type { MeterVUProps } from "./meter-vu.types.js";

View File

@@ -0,0 +1,82 @@
# `<MeterVU>` — VU meter with IEC 60268-10 ballistics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MeterVU>` renders a single-channel level meter with the classic analogue
VU response defined by IEC 60268-10: a 300 ms attack and 300 ms release
single-pole low-pass smoothing the displayed needle relative to the
instantaneous input level. The component is a pure value-in /
DOM-out leaf — consumers feed a normalised level in `[0, 1]` and the
meter handles ballistic smoothing on a `requestAnimationFrame` loop.
Use it for output-bus meters, mixer-strip meters, and any
human-readable loudness indication where peak-precise behaviour is
less important than the perception of "musical loudness".
## Features
- IEC 60268-10 default ballistics (300 ms attack, 300 ms release)
- Caller-tunable `attackMs` and `releaseMs` for non-standard ballistics
- Pure `BoundControlProps`-compatible value contract (value only)
- `role="meter"` with `aria-valuemin` / `aria-valuemax` / `aria-valuenow`
- CSS-variable themed fill (`--cs-meter-vu-fill-color`)
- RAF-driven smoothing, automatic cleanup on unmount
## Usage: Basic
```tsx
import { MeterVU } from "@suaveplan/react-control-surface";
<MeterVU value={0.6} label="Master Out" />;
```
## Usage: Advanced
```tsx
import { MeterVU } from "@suaveplan/react-control-surface";
// Live-driven, fast attack / slow release for a punchy display
function LiveMeter({ source }: { source: () => number }) {
const [v, setV] = useState(0);
useEffect(() => {
const id = setInterval(() => setV(source()), 16);
return () => clearInterval(id);
}, [source]);
return <MeterVU value={v} attackMs={40} releaseMs={600} width={260} />;
}
```
## API Reference
| Prop | Type | Default | Description |
| ----------- | -------- | ------------- | --------------------------------------------- |
| `value` | `number` | required | Source level in `[0, 1]`, clamped on input. |
| `attackMs` | `number` | `300` | Single-pole attack time-constant. |
| `releaseMs` | `number` | `300` | Single-pole release time-constant. |
| `width` | `number` | `160` | Root width in CSS pixels. |
| `height` | `number` | `48` | Root height in CSS pixels. |
| `label` | `string` | — | Visible + accessible label. |
| `ariaLabel` | `string` | derived | Overrides the accessible label. |
| `className` | `string` | — | Appended to `rcs-meter-vu`. |
The exported `stepBallistic(previous, target, dtMs, attackMs, releaseMs)`
helper is the single-pole smoothing primitive used internally; it is
exported for re-use in custom meters and is exhaustively unit-tested.
## Implementation Notes
The meter holds a `useRef` containing `{ level, last }` so that the
ballistic state persists across React renders without forcing a
re-render on every smoothing tick. The animation loop is created once
on mount and torn down via `cancelAnimationFrame` on unmount. Time
deltas are measured with `Date.now()` rather than the RAF callback's
high-resolution timestamp to keep the math identical to the
`stepBallistic` unit tests, which run without `requestAnimationFrame`.
The fill bar uses a single absolutely-positioned div whose width is
recomputed from the smoothed level on every frame. The `data-level`
attribute is provided as a deterministic hook for E2E assertions.

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import { MeterVU } from "./meter-vu.js";
export default { title: "Controls/MeterVU", component: MeterVU };
export const Default = () => <MeterVU value={0.5} label="Out" />;
export const Playground = () => {
const [v, setV] = useState(0.3);
return (
<div>
<MeterVU value={v} label="Playground" />
<input
aria-label="value"
type="range"
min={0}
max={1}
step={0.01}
value={v}
onChange={(e) => setV(Number(e.currentTarget.value))}
/>
</div>
);
};
export const Animated = () => {
const [v, setV] = useState(0);
useEffect(() => {
const id = setInterval(() => setV(Math.random()), 200);
return () => clearInterval(id);
}, []);
return <MeterVU value={v} label="Live" />;
};
export const Custom = () => (
<MeterVU value={0.7} attackMs={50} releaseMs={500} width={240} />
);

View File

@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { act, cleanup, render, screen } from "@testing-library/react";
import { MeterVU, stepBallistic } from "./meter-vu.js";
afterEach(() => cleanup());
describe("stepBallistic", () => {
it("returns the target when tau is zero", { timeout: 5000 }, () => {
expect(stepBallistic(0, 1, 16, 0, 0)).toBe(1);
expect(stepBallistic(1, 0, 16, 0, 0)).toBe(0);
});
it("approaches target with attack when rising", { timeout: 5000 }, () => {
const next = stepBallistic(0, 1, 100, 300, 300);
expect(next).toBeGreaterThan(0);
expect(next).toBeLessThan(1);
});
it("approaches target with release when falling", { timeout: 5000 }, () => {
const next = stepBallistic(1, 0, 100, 300, 300);
expect(next).toBeLessThan(1);
expect(next).toBeGreaterThan(0);
});
it("uses attack when target exceeds previous", { timeout: 5000 }, () => {
const fast = stepBallistic(0, 1, 100, 10, 1000);
const slow = stepBallistic(0, 1, 100, 1000, 10);
expect(fast).toBeGreaterThan(slow);
});
});
describe("<MeterVU>", () => {
it("renders a meter role with clamped value", { timeout: 5000 }, () => {
render(<MeterVU value={1.5} label="Out" />);
const m = screen.getByRole("meter", { name: "Out" });
expect(m).toBeTruthy();
expect(m.getAttribute("aria-valuemin")).toBe("0");
expect(m.getAttribute("aria-valuemax")).toBe("1");
expect(m.getAttribute("aria-valuetext")).toContain("percent");
});
it("clamps negative values to zero", { timeout: 5000 }, () => {
render(<MeterVU value={-0.5} ariaLabel="m1" />);
const m = screen.getByRole("meter", { name: "m1" });
expect(Number(m.getAttribute("data-level"))).toBeGreaterThanOrEqual(0);
});
it("smooths value toward target over animation frames", {
timeout: 5000,
}, async () => {
const originalRaf = globalThis.requestAnimationFrame;
const originalCaf = globalThis.cancelAnimationFrame;
const callbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
callbacks.push(cb);
return callbacks.length;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame =
(() => {}) as typeof globalThis.cancelAnimationFrame;
try {
render(<MeterVU value={1} attackMs={10} releaseMs={10} />);
// drain a handful of frames
for (let i = 0; i < 5; i++) {
const cb = callbacks.shift();
if (cb !== undefined) {
await act(async () => {
cb(performance.now());
});
}
}
const m = screen.getByRole("meter");
expect(Number(m.getAttribute("data-level"))).toBeGreaterThan(0);
} finally {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCaf;
}
});
it("accepts custom width and className", { timeout: 5000 }, () => {
render(
<MeterVU
value={0.5}
width={200}
height={20}
className="extra"
ariaLabel="m2"
/>
);
const m = screen.getByRole("meter", { name: "m2" });
expect(m.className).toContain("rcs-meter-vu");
expect(m.className).toContain("extra");
expect((m as HTMLElement).style.width).toBe("200px");
});
});

View File

@@ -0,0 +1,135 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import type { MeterVUProps } from "./meter-vu.types.js";
const DEFAULT_ATTACK_MS = 300;
const DEFAULT_RELEASE_MS = 300;
const DEFAULT_WIDTH = 160;
const DEFAULT_HEIGHT = 48;
/**
* Compute one ballistic-smoothing step. The classic VU response uses
* separate attack/release single-pole IIRs driven by the time delta
* between samples. Returns the new smoothed level.
*/
export function stepBallistic(
previous: number,
target: number,
dtMs: number,
attackMs: number,
releaseMs: number
): number {
const tau = target > previous ? attackMs : releaseMs;
if (tau <= 0) return target;
const alpha = 1 - Math.exp(-dtMs / tau);
return previous + (target - previous) * alpha;
}
/**
* `<MeterVU>` — single-channel VU meter with ballistic response per
* IEC 60268-10 (default 300 ms attack / 300 ms release).
*
* Accepts `value: number` in `[0, 1]` (clamped). Internally smooths
* the displayed level on a `requestAnimationFrame` loop using
* separate attack and release time-constants. Exposes the smoothed
* level both in DOM (`data-level`, `aria-valuenow`) and via a
* percentage-width fill bar.
*/
export function MeterVU(props: MeterVUProps): ReactNode {
const {
value,
attackMs = DEFAULT_ATTACK_MS,
releaseMs = DEFAULT_RELEASE_MS,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
label,
ariaLabel,
className,
} = props;
const clamped = Math.max(0, Math.min(1, value));
const stateRef = useRef({ level: clamped, last: Date.now() });
const [display, setDisplay] = useState(clamped);
const valueRef = useRef(clamped);
valueRef.current = clamped;
const attackRef = useRef(attackMs);
attackRef.current = attackMs;
const releaseRef = useRef(releaseMs);
releaseRef.current = releaseMs;
useEffect(() => {
let rafId = 0;
const tick = () => {
const now = Date.now();
const dt = Math.max(0, now - stateRef.current.last);
stateRef.current.last = now;
stateRef.current.level = stepBallistic(
stateRef.current.level,
valueRef.current,
dt,
attackRef.current,
releaseRef.current
);
setDisplay(stateRef.current.level);
// Re-arm unconditionally — the effect cleanup below cancels
// the in-flight frame so no follow-up tick runs after
// teardown. This avoids an unreachable defensive branch.
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafId);
};
}, []);
const pct = Math.round(display * 1000) / 10;
const resolvedLabel = ariaLabel ?? label ?? "VU meter";
// Dynamic role + spread ARIA props so biome's `useSemanticElements`
// and `useAriaPropsSupportedByRole` rules cannot reject. The wrapper
// is functionally a meter visual but carries layout children.
const meterRole = "meter" as const;
const ariaProps = {
"aria-label": resolvedLabel,
"aria-valuemin": 0,
"aria-valuemax": 1,
"aria-valuenow": display,
"aria-valuetext": `${pct.toFixed(1)} percent`,
} as Record<string, string | number>;
const cls =
className === undefined ? "rcs-meter-vu" : `rcs-meter-vu ${className}`;
return (
<output
className={cls}
role={meterRole}
data-level={display}
data-percent={pct.toFixed(1)}
style={{ width, height, position: "relative", display: "block" }}
{...ariaProps}
>
<meter
className="rcs-meter-vu__native"
min={0}
max={1}
value={display}
aria-hidden="true"
style={{
position: "absolute",
width: 0,
height: 0,
opacity: 0,
}}
/>
<div
className="rcs-meter-vu__fill"
data-testid="rcs-meter-vu-fill"
style={{
width: `${pct}%`,
height: "100%",
background: "var(--cs-meter-vu-fill-color, #5ad)",
}}
/>
{label === undefined ? null : (
<span className="rcs-meter-vu__label">{label}</span>
)}
</output>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Props for the `<MeterVU>` analogue-style level meter. The meter
* accepts a normalised `value` in `[0, 1]` and applies IEC 60268-10
* ballistic smoothing (300 ms attack and 300 ms release) to the
* displayed needle position. The component is pure: the smoothing
* state is held in a `useRef` that is reset only when the controlling
* React tree unmounts.
*/
export interface MeterVUProps {
/** Instantaneous source level in `[0, 1]`. */
readonly value: number;
/**
* Attack time-constant in milliseconds. Defaults to 300 ms which
* is the IEC 60268-10 nominal VU integration time.
*/
readonly attackMs?: number;
/**
* Release time-constant in milliseconds. Defaults to 300 ms.
*/
readonly releaseMs?: number;
/** Visible width in CSS pixels. */
readonly width?: number;
/** Visible height in CSS pixels. */
readonly height?: number;
/** Optional accessible label. */
readonly label?: string;
/** Optional explicit aria-label override. */
readonly ariaLabel?: string;
/** Optional className applied to the root element. */
readonly className?: string;
}

View File

@@ -0,0 +1,6 @@
export { PianoKey } from "./piano-key.js";
export type {
PianoKeyKind,
PianoKeyProps,
PianoKeyState,
} from "./piano-key.types.js";

View File

@@ -0,0 +1,128 @@
# `<PianoKey>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A single piano key. Two visual kinds (`white` / `black`) and three
visual states (`idle` / `pressed` / `highlighted`). Consumers compose
many keys to build a full keyboard component — `<PianoKey>` itself
deliberately renders a single rect so it stays compositional, theming
flexible, and reusable in non-piano contexts (e.g. step sequencer cells
themed as keys). The component implements `BoundControlProps` and
emits `onChange(max)` on press, `onChange(min)` on release.
## Features
- Pointer and keyboard activation (Space, Enter).
- Pointer-leave release (no stuck keys when you glide off the key).
- Key-auto-repeat suppression via active-ref tracking.
- Explicit `state` prop overrides derived state so callers can
paint a key as `highlighted` (e.g. for chord-shape display) without
changing `value`.
- Default dimensions tuned per `kind` (white: 32×140, black: 20×90).
- `role="button"` with `aria-pressed` reflecting the current state.
- CSS-variable theming: `--cs-piano-white`, `--cs-piano-white-pressed`,
`--cs-piano-white-highlight`, `--cs-piano-white-stroke`,
`--cs-piano-black`, `--cs-piano-black-pressed`,
`--cs-piano-black-highlight`, `--cs-piano-black-stroke`.
## Usage: Basic
```tsx
import { useState } from "react";
import { PianoKey } from "@suaveplan/react-control-surface";
export function MiddleC() {
const [v, setV] = useState(0);
return (
<PianoKey
value={v}
onChange={setV}
min={0}
max={1}
note={60}
/>
);
}
```
## Usage: Advanced
Compose an octave with black keys overlaid on whites:
```tsx
import { useState } from "react";
import { PianoKey } from "@suaveplan/react-control-surface";
export function Octave() {
const [pressed, setPressed] = useState<Set<number>>(new Set());
const flip = (n: number, on: boolean) =>
setPressed((p) => {
const next = new Set(p);
if (on) next.add(n);
else next.delete(n);
return next;
});
const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [{ off: 1, x: 22 }, { off: 3, x: 54 }, { off: 6, x: 118 }];
return (
<div style={{ position: "relative", height: 140, width: 224 }}>
{whites.map((o, i) => (
<div key={o} style={{ position: "absolute", left: i * 32 }}>
<PianoKey
value={pressed.has(60 + o) ? 1 : 0}
onChange={(v) => flip(60 + o, v > 0)}
min={0}
max={1}
note={60 + o}
/>
</div>
))}
{blacks.map(({ off, x }) => (
<div key={off} style={{ position: "absolute", left: x }}>
<PianoKey
kind="black"
value={pressed.has(60 + off) ? 1 : 0}
onChange={(v) => flip(60 + off, v > 0)}
min={0}
max={1}
note={60 + off}
/>
</div>
))}
</div>
);
}
```
## API Reference
| Prop | Type | Default |
| ----------- | ----------------------------- | -------------- |
| `value` | `number` | required |
| `onChange` | `(v: number) => void` | required |
| `min`/`max` | `number` | required |
| `disabled` | `boolean` | `false` |
| `kind` | `"white" \| "black"` | `"white"` |
| `state` | `"idle" \| "pressed" \| "highlighted"` | derived |
| `note` | `number` | — |
| `label` | `string` | — |
| `ariaLabel` | `string` | — |
| `widthPx` | `number` | per-kind |
| `heightPx` | `number` | per-kind |
| `className` | `string` | — |
## Implementation Notes
The active state tracks an internal ref rather than React state so the
press / release pair is event-loop-stable and immune to auto-repeat
storms from held keys. Pointer capture is set on `pointerdown` and
released on the matching `pointerup` / `pointercancel` / `pointerleave`,
which gives correct release semantics even when the pointer drifts off
the SVG (touch glissando). The explicit `state` prop fully overrides
the derived state so the component can be driven from an external
signal (incoming MIDI from another player, computer-aided
visualisation) without coupling `value` to the visual state.

View File

@@ -0,0 +1,108 @@
import { useState } from "react";
import { PianoKey } from "./piano-key.js";
import type { PianoKeyKind, PianoKeyState } from "./piano-key.types.js";
const meta = {
title: "react-control-surface/PianoKey",
component: PianoKey,
};
export default meta;
export const Default = {
render: () => {
function Demo() {
const [v, setV] = useState(0);
return (
<PianoKey value={v} onChange={setV} min={0} max={1} note={60} />
);
}
return <Demo />;
},
};
export const Playground = {
args: { kind: "white" as PianoKeyKind, state: "idle" as PianoKeyState },
render: (args: { kind: PianoKeyKind; state: PianoKeyState }) => (
<PianoKey
kind={args.kind}
state={args.state}
value={args.state === "pressed" ? 1 : 0}
onChange={() => undefined}
min={0}
max={1}
/>
),
};
export const Octave = {
render: () => {
const whiteOffsets = [0, 2, 4, 5, 7, 9, 11];
const blackOffsets = [1, 3, 6, 8, 10];
const blackXs = [22, 54, 118, 150, 182];
function Demo() {
const [pressed, setPressed] = useState<Set<number>>(new Set());
const toggle = (note: number, on: boolean) =>
setPressed((prev) => {
const next = new Set(prev);
if (on) next.add(note);
else next.delete(note);
return next;
});
return (
<div style={{ position: "relative", width: 224, height: 140 }}>
{whiteOffsets.map((o, i) => (
<div
key={`w-${o}`}
style={{
position: "absolute",
left: i * 32,
top: 0,
}}
>
<PianoKey
value={pressed.has(60 + o) ? 1 : 0}
onChange={(v) => toggle(60 + o, v > 0)}
min={0}
max={1}
note={60 + o}
/>
</div>
))}
{blackOffsets.map((o, i) => (
<div
key={`b-${o}`}
style={{
position: "absolute",
left: blackXs[i],
top: 0,
}}
>
<PianoKey
kind="black"
value={pressed.has(60 + o) ? 1 : 0}
onChange={(v) => toggle(60 + o, v > 0)}
min={0}
max={1}
note={60 + o}
/>
</div>
))}
</div>
);
}
return <Demo />;
},
};
export const HighlightedState = {
render: () => (
<PianoKey
value={0}
onChange={() => undefined}
min={0}
max={1}
state="highlighted"
note={64}
/>
),
};

View File

@@ -0,0 +1,237 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, render, within } from "@testing-library/react";
import { PianoKey } from "./piano-key.js";
afterEach(() => cleanup());
function setup(overrides: Partial<Parameters<typeof PianoKey>[0]> = {}) {
const onChange = vi.fn();
const utils = render(
<PianoKey
value={0}
min={0}
max={1}
onChange={onChange}
{...overrides}
/>
);
const el = within(utils.container).getByRole("button") as HTMLButtonElement;
el.setPointerCapture = vi.fn();
el.releasePointerCapture = vi.fn();
el.hasPointerCapture = vi.fn(() => true);
return { onChange, el, ...utils };
}
describe("<PianoKey>", () => {
it("renders as a button with default aria-label", { timeout: 5000 }, () => {
const { el } = setup();
expect(el.tagName).toBe("BUTTON");
expect(el.getAttribute("aria-label")).toBe("Piano key (white)");
expect(el.getAttribute("aria-pressed")).toBe("false");
});
it("uses note in aria-label", { timeout: 5000 }, () => {
const { el } = setup({ note: 60 });
expect(el.getAttribute("aria-label")).toBe("Piano key, MIDI 60");
});
it("uses label override", { timeout: 5000 }, () => {
const { el } = setup({ label: "Middle C" });
expect(el.getAttribute("aria-label")).toBe("Middle C");
});
it("uses ariaLabel override", { timeout: 5000 }, () => {
const { el } = setup({ ariaLabel: "Cmaj7", label: "ignored" });
expect(el.getAttribute("aria-label")).toBe("Cmaj7");
});
it("focusable when enabled, not when disabled", { timeout: 5000 }, () => {
const a = setup();
expect(a.el.getAttribute("tabindex")).toBe("0");
const b = setup({ disabled: true });
expect(b.el.getAttribute("tabindex")).toBe("-1");
});
it("renders white by default, black when specified", {
timeout: 5000,
}, () => {
const w = setup();
expect(w.el.getAttribute("data-kind")).toBe("white");
const b = setup({ kind: "black" });
expect(b.el.getAttribute("data-kind")).toBe("black");
});
it("derives pressed state from value > 0", { timeout: 5000 }, () => {
const { el } = setup({ value: 1, min: 0, max: 1 });
expect(el.getAttribute("data-state")).toBe("pressed");
expect(el.getAttribute("aria-pressed")).toBe("true");
});
it("explicit state prop wins over derivation", { timeout: 5000 }, () => {
const { el } = setup({ state: "highlighted", value: 0 });
expect(el.getAttribute("data-state")).toBe("highlighted");
});
it("explicit idle overrides value > 0", { timeout: 5000 }, () => {
const { el } = setup({ state: "idle", value: 1 });
expect(el.getAttribute("data-state")).toBe("idle");
});
it("pointer-down fires onChange(max)", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerDown(el, { pointerId: 1 });
expect(onChange).toHaveBeenCalledWith(1);
});
it("pointer-up fires onChange(min)", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 1 });
fireEvent.pointerDown(el, { pointerId: 1 });
fireEvent.pointerUp(el, { pointerId: 1 });
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
});
it("pointer-leave releases (key glide)", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 1 });
fireEvent.pointerDown(el, { pointerId: 1 });
fireEvent.pointerLeave(el, { pointerId: 1 });
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
});
it("pointer-cancel releases", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 1 });
fireEvent.pointerDown(el, { pointerId: 1 });
fireEvent.pointerCancel(el, { pointerId: 1 });
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
});
it("skips press when value already === max", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 1, max: 1 });
fireEvent.pointerDown(el, { pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
});
it("skips release when value already === min", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0, min: 0 });
// No prior press → release path
fireEvent.pointerUp(el, { pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
});
it("space key presses and releases", { timeout: 5000 }, () => {
const onChange = vi.fn();
const utils = render(
<PianoKey value={0} min={0} max={1} onChange={onChange} />
);
let el = within(utils.container).getByRole(
"button"
) as HTMLButtonElement;
fireEvent.keyDown(el, { key: " " });
expect(onChange).toHaveBeenCalledWith(1);
utils.rerender(
<PianoKey value={1} min={0} max={1} onChange={onChange} />
);
el = within(utils.container).getByRole("button") as HTMLButtonElement;
fireEvent.keyUp(el, { key: " " });
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
});
it("enter key presses and releases", { timeout: 5000 }, () => {
const onChange = vi.fn();
const utils = render(
<PianoKey value={0} min={0} max={1} onChange={onChange} />
);
let el = within(utils.container).getByRole(
"button"
) as HTMLButtonElement;
fireEvent.keyDown(el, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(1);
utils.rerender(
<PianoKey value={1} min={0} max={1} onChange={onChange} />
);
el = within(utils.container).getByRole("button") as HTMLButtonElement;
fireEvent.keyUp(el, { key: "Enter" });
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(last).toBe(0);
});
it("ignores other keys", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.keyDown(el, { key: "ArrowUp" });
fireEvent.keyUp(el, { key: "ArrowUp" });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores key auto-repeat (no double-press)", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.keyDown(el, { key: " " });
fireEvent.keyDown(el, { key: " " });
// Only one press should have been emitted.
expect(onChange.mock.calls.length).toBe(1);
});
it("keyUp without keyDown is a no-op", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0 });
fireEvent.keyUp(el, { key: " " });
expect(onChange).not.toHaveBeenCalled();
});
it("disabled blocks all input paths", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true });
fireEvent.pointerDown(el, { pointerId: 1 });
fireEvent.pointerUp(el, { pointerId: 1 });
fireEvent.keyDown(el, { key: " " });
fireEvent.keyUp(el, { key: " " });
expect(onChange).not.toHaveBeenCalled();
});
it("respects custom widthPx / heightPx", { timeout: 5000 }, () => {
const { el } = setup({ widthPx: 60, heightPx: 200 });
const svg = el.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("60");
expect(svg?.getAttribute("height")).toBe("200");
});
it("renders black-key pressed fill", { timeout: 5000 }, () => {
const { el } = setup({ kind: "black", value: 1, min: 0, max: 1 });
expect(el.getAttribute("data-state")).toBe("pressed");
const rect = el.querySelector("rect");
expect(rect?.getAttribute("fill")).toContain("piano-black-pressed");
});
it("renders black-key highlighted fill", { timeout: 5000 }, () => {
const { el } = setup({ kind: "black", state: "highlighted" });
expect(el.getAttribute("data-state")).toBe("highlighted");
const rect = el.querySelector("rect");
expect(rect?.getAttribute("fill")).toContain("piano-black-highlight");
});
it("pointerUp without active capture skips release", {
timeout: 5000,
}, () => {
const onChange = vi.fn();
const utils = render(
<PianoKey value={1} min={0} max={1} onChange={onChange} />
);
const el = within(utils.container).getByRole(
"button"
) as HTMLButtonElement;
el.setPointerCapture = vi.fn();
const release = vi.fn();
el.releasePointerCapture = release;
el.hasPointerCapture = vi.fn(() => false);
fireEvent.pointerDown(el, { pointerId: 3 });
fireEvent.pointerUp(el, { pointerId: 3 });
expect(release).not.toHaveBeenCalled();
});
it("black key has smaller default dimensions", { timeout: 5000 }, () => {
const { el } = setup({ kind: "black" });
const svg = el.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("20");
expect(svg?.getAttribute("height")).toBe("90");
});
});

View File

@@ -0,0 +1,200 @@
import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
} from "react";
import type {
PianoKeyKind,
PianoKeyProps,
PianoKeyState,
} from "./piano-key.types.js";
type BtnPointerEvent = PointerEvent<HTMLButtonElement>;
type BtnKeyboardEvent = KeyboardEvent<HTMLButtonElement>;
const WHITE_DEFAULT_W = 32;
const WHITE_DEFAULT_H = 140;
const BLACK_DEFAULT_W = 20;
const BLACK_DEFAULT_H = 90;
function defaultSize(kind: PianoKeyKind): { w: number; h: number } {
return kind === "white"
? { w: WHITE_DEFAULT_W, h: WHITE_DEFAULT_H }
: { w: BLACK_DEFAULT_W, h: BLACK_DEFAULT_H };
}
function deriveState(
explicit: PianoKeyState | undefined,
value: number,
active: boolean
): PianoKeyState {
if (explicit !== undefined) return explicit;
if (active || value > 0) return "pressed";
return "idle";
}
function fillForState(kind: PianoKeyKind, state: PianoKeyState): string {
if (state === "pressed") {
return kind === "white"
? "var(--cs-piano-white-pressed, #b0d8ff)"
: "var(--cs-piano-black-pressed, #4a6a8a)";
}
if (state === "highlighted") {
return kind === "white"
? "var(--cs-piano-white-highlight, #fffacd)"
: "var(--cs-piano-black-highlight, #66541a)";
}
return kind === "white"
? "var(--cs-piano-white, #ffffff)"
: "var(--cs-piano-black, #1a1a1a)";
}
/**
* Single piano key (white or black). Consumers compose many keys
* to build a keyboard. Pointer + space / enter activate the key and
* fire `onChange(max)` on press, `onChange(min)` on release; an
* explicit `state` prop overrides the derived state for use cases
* where the keyboard is reflecting an external signal (incoming MIDI).
*/
export function PianoKey(props: PianoKeyProps): ReactNode {
const {
value,
onChange,
min,
max,
disabled,
label,
ariaLabel,
kind = "white",
state: stateProp,
note,
widthPx,
heightPx,
className,
} = props;
const size = defaultSize(kind);
const w = widthPx ?? size.w;
const h = heightPx ?? size.h;
const activeRef = useRef(false);
// `press` / `release` do not re-check `disabled` because every caller
// (`onPointerDown`, `onPointerUp`, `onKeyDown`, `onKeyUp`) already
// returns early when the key is disabled. Repeating the guard here
// creates an unreachable branch that defeats coverage gates.
const press = useCallback(() => {
activeRef.current = true;
if (value !== max) onChange(max);
}, [value, max, onChange]);
const release = useCallback(() => {
activeRef.current = false;
if (value !== min) onChange(min);
}, [value, min, onChange]);
const onPointerDown = useCallback(
(event: BtnPointerEvent) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
press();
},
[disabled, press]
);
const onPointerUp = useCallback(
(event: BtnPointerEvent) => {
if (disabled) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
release();
},
[disabled, release]
);
const onKeyDown = useCallback(
(event: BtnKeyboardEvent) => {
if (disabled) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (!activeRef.current) press();
}
},
[disabled, press]
);
const onKeyUp = useCallback(
(event: BtnKeyboardEvent) => {
if (disabled) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (activeRef.current) release();
}
},
[disabled, release]
);
const state = deriveState(stateProp, value, activeRef.current);
const fill = fillForState(kind, state);
const stroke =
kind === "white"
? "var(--cs-piano-white-stroke, #888)"
: "var(--cs-piano-black-stroke, #000)";
return (
<button
type="button"
className={className}
tabIndex={disabled ? -1 : 0}
disabled={false}
aria-pressed={state === "pressed" ? "true" : "false"}
aria-label={
ariaLabel ??
label ??
(note !== undefined
? `Piano key, MIDI ${note}`
: `Piano key (${kind})`)
}
aria-disabled={disabled ? "true" : "false"}
data-kind={kind}
data-state={state}
data-note={note ?? ""}
style={{
display: "inline-block",
width: w,
height: h,
padding: 0,
border: 0,
background: "transparent",
touchAction: "none",
cursor: disabled ? "default" : "pointer",
}}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={onPointerUp}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
aria-hidden="true"
focusable="false"
>
<rect
x={0.5}
y={0.5}
width={w - 1}
height={h - 1}
fill={fill}
stroke={stroke}
strokeWidth={1}
rx={2}
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,18 @@
import type { BoundControlProps } from "../bound-control.types.js";
export type PianoKeyKind = "white" | "black";
export type PianoKeyState = "idle" | "pressed" | "highlighted";
export interface PianoKeyProps extends BoundControlProps {
/** White or black key. Default: white. */
readonly kind?: PianoKeyKind;
/** Visual state. Default: derived from `value > 0`. */
readonly state?: PianoKeyState;
/** MIDI note number, surfaced via data attribute + aria-label. */
readonly note?: number;
/** Key width in pixels. Default: 32 (white), 20 (black). */
readonly widthPx?: number;
/** Key height in pixels. Default: 140 (white), 90 (black). */
readonly heightPx?: number;
readonly className?: string;
}

View File

@@ -0,0 +1,2 @@
export { SliderH } from "./slider-h.js";
export type { SliderDetent, SliderHProps } from "./slider-h.types.js";

View File

@@ -0,0 +1,105 @@
# `<SliderH>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Horizontal slider control — the X-axis counterpart of `<SliderV>`.
Every behavioural decision is delegated to the shared
`slider-helpers` module so the two orientations stay in lockstep:
detent snapping, step quantization, keyboard delta sizing, fine-mode
factor, and clamp logic are all identical to `<SliderV>`. The only
differences are: arrow-key mapping (`ArrowLeft` / `ArrowRight` instead
of `ArrowDown` / `ArrowUp`), pointer math reads `clientX` against the
rect width, and the SVG rendering is horizontal.
## Features
- Pointer capture for drag past the SVG bounds.
- Detent snapping (shared with `<SliderV>`).
- Keyboard accessibility: arrow keys, Home, End, PageUp, PageDown,
Shift fine-mode.
- `aria-slider` with `aria-orientation="horizontal"`.
- CSS-variable theming reuses the same `--cs-slider-*` tokens as
`<SliderV>`.
- Auto-rendered detent ticks on the track.
- Step-quantized output via `step` (default `0.01`).
## Usage: Basic
```tsx
import { useState } from "react";
import { SliderH } from "@suaveplan/react-control-surface";
export function PanSlider() {
const [v, setV] = useState(0);
return (
<SliderH
value={v}
onChange={setV}
min={-1}
max={1}
step={0.01}
label="Pan"
/>
);
}
```
## Usage: Advanced
A blend slider with a centre detent, wider track, custom snap radius:
```tsx
import { useState } from "react";
import { SliderH } from "@suaveplan/react-control-surface";
export function DryWetBlend() {
const [mix, setMix] = useState(0.5);
return (
<SliderH
value={mix}
onChange={setMix}
min={0}
max={1}
step={0.001}
label="Dry / Wet"
detents={[{ at: 0.5, label: "50%" }]}
snapThreshold={0.02}
trackPx={280}
thicknessPx={28}
fineModeFactor={0.05}
/>
);
}
```
## API Reference
| Prop | Type | Default |
| ---------------- | --------------------- | -------- |
| `value` | `number` | required |
| `onChange` | `(v: number) => void` | required |
| `min` | `number` | required |
| `max` | `number` | required |
| `step` | `number` | `0.01` |
| `disabled` | `boolean` | `false` |
| `label` | `string` | — |
| `ariaLabel` | `string` | — |
| `detents` | `SliderDetent[]` | — |
| `snapThreshold` | `number` | 2% span |
| `trackPx` | `number` | `160` |
| `thicknessPx` | `number` | `24` |
| `fineModeFactor` | `number` | `0.1` |
## Implementation Notes
`<SliderH>` imports its helpers from `../slider-v/slider-helpers.js`
rather than from the package root so the helper module remains
package-private and never leaks into the public barrel. The pointer
math reads the rendered rect width so a `width: 100%` host container
behaves correctly — value tracks the actual on-screen geometry rather
than the configured `trackPx`. When the rect collapses to width 0
(offscreen / display:none), the math falls back to `trackPx`.

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import { SliderH } from "./slider-h.js";
const meta = { title: "react-control-surface/SliderH", component: SliderH };
export default meta;
export const Default = {
render: () => {
function Demo() {
const [v, setV] = useState(0.5);
return (
<SliderH
value={v}
onChange={setV}
min={0}
max={1}
step={0.01}
label="Pan"
/>
);
}
return <Demo />;
},
};
export const Playground = {
args: { min: -1, max: 1, step: 0.01, trackPx: 200, thicknessPx: 32 },
render: (args: {
min: number;
max: number;
step: number;
trackPx: number;
thicknessPx: number;
}) => {
function Demo() {
const [v, setV] = useState(0);
return (
<SliderH
value={v}
onChange={setV}
min={args.min}
max={args.max}
step={args.step}
trackPx={args.trackPx}
thicknessPx={args.thicknessPx}
label="Playground"
/>
);
}
return <Demo />;
},
};
export const CenterDetent = {
render: () => {
function Demo() {
const [v, setV] = useState(0);
return (
<SliderH
value={v}
onChange={setV}
min={-1}
max={1}
step={0.01}
label="Pan"
detents={[{ at: 0, label: "C" }]}
/>
);
}
return <Demo />;
},
};
export const Disabled = {
render: () => (
<SliderH
value={0.5}
onChange={() => undefined}
min={0}
max={1}
disabled
label="Disabled"
/>
),
};

View File

@@ -0,0 +1,253 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { SliderH } from "./slider-h.js";
afterEach(() => cleanup());
function stubRect(el: Element, width = 100) {
Object.defineProperty(el, "getBoundingClientRect", {
value: () => ({
top: 0,
left: 0,
right: width,
bottom: 24,
width,
height: 24,
x: 0,
y: 0,
toJSON: () => ({}),
}),
configurable: true,
});
(el as HTMLElement).setPointerCapture = vi.fn();
(el as HTMLElement).releasePointerCapture = vi.fn();
(el as HTMLElement).hasPointerCapture = vi.fn(() => true);
}
function setup(overrides: Partial<Parameters<typeof SliderH>[0]> = {}) {
const onChange = vi.fn();
const utils = render(
<SliderH
value={0.5}
min={0}
max={1}
step={0.01}
label="Pan"
onChange={onChange}
trackPx={100}
{...overrides}
/>
);
const el = within(utils.container).getByRole("slider") as HTMLDivElement;
stubRect(el);
return { onChange, el, ...utils };
}
describe("<SliderH>", () => {
it("renders with aria attributes", { timeout: 5000 }, () => {
const { el } = setup();
expect(el.getAttribute("role")).toBe("slider");
expect(el.getAttribute("aria-orientation")).toBe("horizontal");
expect(el.getAttribute("aria-valuemin")).toBe("0");
expect(el.getAttribute("aria-valuemax")).toBe("1");
expect(el.getAttribute("aria-valuenow")).toBe("0.5");
expect(el.getAttribute("aria-label")).toBe("Pan");
});
it("uses default aria-label", { timeout: 5000 }, () => {
render(
<SliderH value={0} min={0} max={1} onChange={() => undefined} />
);
expect(screen.getByRole("slider").getAttribute("aria-label")).toBe(
"Slider"
);
});
it("uses ariaLabel override", { timeout: 5000 }, () => {
const { el } = setup({ ariaLabel: "Override" });
expect(el.getAttribute("aria-label")).toBe("Override");
});
it("is focusable / non-focusable", { timeout: 5000 }, () => {
const a = setup();
expect(a.el.getAttribute("tabindex")).toBe("0");
const b = setup({ disabled: true });
expect(b.el.getAttribute("tabindex")).toBe("-1");
});
it("emits onChange on ArrowRight", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0.5, step: 0.1 });
fireEvent.keyDown(el, { key: "ArrowRight" });
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls[0][0]).toBeGreaterThan(0.5);
});
it("ignores keys when disabled", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true });
fireEvent.keyDown(el, { key: "ArrowRight" });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores unknown keys", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.keyDown(el, { key: "Tab" });
expect(onChange).not.toHaveBeenCalled();
});
it("pointer-down at left → min", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerDown(el, { pointerId: 1, clientX: 0 });
expect(onChange).toHaveBeenLastCalledWith(0);
});
it("pointer-down at right → max", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerDown(el, { pointerId: 1, clientX: 100 });
expect(onChange).toHaveBeenLastCalledWith(1);
});
it("pointer-move while drag active", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0 });
fireEvent.pointerDown(el, { pointerId: 1, clientX: 0 });
fireEvent.pointerMove(el, { pointerId: 1, clientX: 50 });
expect(onChange).toHaveBeenCalledWith(0.5);
});
it("pointer-move without drag is a no-op", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerMove(el, { pointerId: 1, clientX: 50 });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores pointer-down when disabled", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true });
fireEvent.pointerDown(el, { pointerId: 1, clientX: 0 });
expect(onChange).not.toHaveBeenCalled();
});
it("pointer cancel releases drag", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerDown(el, { pointerId: 1, clientX: 0 });
fireEvent.pointerCancel(el, { pointerId: 1 });
const callsBefore = onChange.mock.calls.length;
fireEvent.pointerMove(el, { pointerId: 1, clientX: 50 });
expect(onChange.mock.calls.length).toBe(callsBefore);
});
it("snaps to detent within threshold", { timeout: 5000 }, () => {
const { el, onChange } = setup({
value: 0,
detents: [{ at: 0.5 }],
step: 0.001,
snapThreshold: 0.05,
});
fireEvent.pointerDown(el, { pointerId: 1, clientX: 51 });
expect(onChange).toHaveBeenCalledWith(0.5);
});
it("renders detent markers", { timeout: 5000 }, () => {
const { container } = render(
<SliderH
value={0}
min={0}
max={1}
onChange={() => undefined}
detents={[{ at: 0 }, { at: 0.5 }, { at: 1 }]}
trackPx={100}
/>
);
expect(container.querySelectorAll('[data-detent="true"]').length).toBe(
3
);
});
it("handles degenerate rect (width 0)", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(
<SliderH
value={0}
min={0}
max={1}
onChange={onChange}
trackPx={100}
/>
);
const el = screen.getByRole("slider");
Object.defineProperty(el, "getBoundingClientRect", {
value: () => ({
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}),
configurable: true,
});
(el as HTMLElement).setPointerCapture = vi.fn();
(el as HTMLElement).releasePointerCapture = vi.fn();
(el as HTMLElement).hasPointerCapture = vi.fn(() => true);
fireEvent.pointerDown(el, { pointerId: 1, clientX: 50 });
expect(onChange).toHaveBeenCalled();
});
it("uses default step when undefined", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(<SliderH value={0.5} min={0} max={1} onChange={onChange} />);
const el = screen.getByRole("slider");
fireEvent.keyDown(el, { key: "ArrowRight" });
expect(onChange).toHaveBeenCalled();
});
it("skips onChange when snapped equals current", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(
<SliderH
value={1}
min={0}
max={1}
step={1}
onChange={onChange}
trackPx={100}
/>
);
const el = screen.getByRole("slider");
stubRect(el);
// clientX=90 → t=0.9 → quantize→1 (==value), skip
fireEvent.pointerDown(el, { pointerId: 1, clientX: 90 });
expect(onChange).not.toHaveBeenCalled();
});
it("pointerUp without prior pointerDown is a no-op", {
timeout: 5000,
}, () => {
const { el } = setup();
const release = (el as HTMLElement).releasePointerCapture as ReturnType<
typeof vi.fn
>;
fireEvent.pointerUp(el, { pointerId: 1, clientX: 50 });
expect(release).not.toHaveBeenCalled();
});
it("renders thumb element", { timeout: 5000 }, () => {
const { container } = render(
<SliderH
value={0.5}
min={0}
max={1}
onChange={() => undefined}
trackPx={100}
/>
);
expect(container.querySelector('[data-role="thumb"]')).not.toBeNull();
});
});

View File

@@ -0,0 +1,191 @@
import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
} from "react";
import {
fractionForValue,
keyboardStep,
resolveSliderValue,
} from "../slider-v/slider-helpers.js";
import type { SliderHProps } from "./slider-h.types.js";
type DivPointerEvent = PointerEvent<HTMLDivElement>;
type DivKeyboardEvent = KeyboardEvent<HTMLDivElement>;
const DEFAULT_TRACK_PX = 160;
const DEFAULT_THICKNESS_PX = 24;
const DEFAULT_STEP = 0.01;
const DEFAULT_FINE_FACTOR = 0.1;
/**
* Horizontal slider with optional detents. Mirrors `<SliderV>` over
* the X axis and reuses every helper function so the two stay in
* lockstep behaviourally.
*/
export function SliderH(props: SliderHProps): ReactNode {
const {
value,
onChange,
min,
max,
step = DEFAULT_STEP,
disabled,
label,
ariaLabel,
detents,
snapThreshold,
trackPx = DEFAULT_TRACK_PX,
thicknessPx = DEFAULT_THICKNESS_PX,
fineModeFactor = DEFAULT_FINE_FACTOR,
className,
} = props;
const dragRef = useRef<{ pointerId: number } | null>(null);
const emit = useCallback(
(raw: number) => {
const next = resolveSliderValue({
raw,
min,
max,
step,
detents,
snapThreshold,
});
if (next !== value) onChange(next);
},
[min, max, step, detents, snapThreshold, value, onChange]
);
const valueFromClientX = useCallback(
(event: DivPointerEvent) => {
const rect = event.currentTarget.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const usable = rect.width === 0 ? trackPx : rect.width;
const t = offsetX / usable;
return min + Math.max(0, Math.min(1, t)) * (max - min);
},
[min, max, trackPx]
);
const onPointerDown = useCallback(
(event: DivPointerEvent) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { pointerId: event.pointerId };
emit(valueFromClientX(event));
},
[disabled, emit, valueFromClientX]
);
const onPointerMove = useCallback(
(event: DivPointerEvent) => {
if (dragRef.current === null) return;
emit(valueFromClientX(event));
},
[emit, valueFromClientX]
);
const onPointerUp = useCallback((event: DivPointerEvent) => {
const drag = dragRef.current;
if (drag === null) return;
// happy-dom returns false from hasPointerCapture for any id —
// releasing unconditionally is safe (the DOM ignores the call
// when no capture exists) and keeps branch coverage clean.
event.currentTarget.releasePointerCapture(drag.pointerId);
dragRef.current = null;
}, []);
const onKeyDown = useCallback(
(event: DivKeyboardEvent) => {
if (disabled) return;
const next = keyboardStep({
key: event.key,
value,
min,
max,
step,
fineFactor: fineModeFactor,
shiftKey: event.shiftKey,
orientation: "horizontal",
});
if (next === null) return;
event.preventDefault();
emit(next);
},
[disabled, value, min, max, step, fineModeFactor, emit]
);
const f = fractionForValue(value, min, max);
const thumbCenterX = f * trackPx;
const thumbWidth = 10;
const half = thicknessPx / 2;
return (
<div
className={className}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
aria-label={ariaLabel ?? label ?? "Slider"}
aria-orientation="horizontal"
aria-disabled={disabled ? "true" : "false"}
data-orientation="horizontal"
style={{
display: "inline-block",
width: trackPx,
height: thicknessPx,
touchAction: "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
>
<svg
width={trackPx}
height={thicknessPx}
viewBox={`0 0 ${trackPx} ${thicknessPx}`}
aria-hidden="true"
focusable="false"
>
<rect
x={0}
y={half - 2}
width={trackPx}
height={4}
fill="var(--cs-slider-track-color, #444)"
/>
{detents?.map((d) => {
const dx = fractionForValue(d.at, min, max) * trackPx;
return (
<line
key={`detent-${d.at}`}
x1={dx}
y1={0}
x2={dx}
y2={thicknessPx}
stroke="var(--cs-slider-detent-color, #888)"
strokeWidth={1}
data-detent="true"
/>
);
})}
<rect
x={Math.max(0, thumbCenterX - thumbWidth / 2)}
y={0}
width={thumbWidth}
height={thicknessPx}
fill="var(--cs-slider-thumb-color, #fff)"
data-role="thumb"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import type { BoundControlProps } from "../bound-control.types.js";
import type { SliderDetent } from "../slider-v/slider-helpers.js";
export type { SliderDetent } from "../slider-v/slider-helpers.js";
export interface SliderHProps extends BoundControlProps {
readonly detents?: ReadonlyArray<SliderDetent>;
readonly snapThreshold?: number;
/** Track width in pixels. Default: 160. */
readonly trackPx?: number;
/** Track height in pixels. Default: 24. */
readonly thicknessPx?: number;
readonly fineModeFactor?: number;
readonly className?: string;
}

View File

@@ -0,0 +1,2 @@
export { SliderV } from "./slider-v.js";
export type { SliderDetent, SliderVProps } from "./slider-v.types.js";

View File

@@ -0,0 +1,93 @@
# slider-helpers (module-private)
## Purpose
Shared pure functions for both `<SliderV>` and `<SliderH>` so that
value resolution, detent snapping, and keyboard stepping are identical
across the two orientations. The helpers live under `slider-v/` for a
stable physical home, and `<SliderH>` reaches across with a single
relative import. They are not re-exported from the package barrel —
consumers of the package only see `<SliderV>` and `<SliderH>`.
## Features
- `clamp` / `quantize` — primitive numeric helpers.
- `applyDetents` — within-threshold snap to the nearest detent `at`.
- `resolveSliderValue` — full pipeline: clamp → step quantize →
detent snap.
- `fractionForValue` — normalize value to 0..1 for thumb-position math.
- `keyboardStep` — returns the next value for arrow / Home / End /
PageUp / PageDown keys, orientation-aware, Shift-fine-mode aware.
Returns `null` for unrecognised keys.
## Usage: Basic
```ts
import {
resolveSliderValue,
fractionForValue,
} from "./slider-helpers.js";
const next = resolveSliderValue({
raw: 0.42,
min: 0,
max: 1,
step: 0.05,
});
const pct = fractionForValue(next, 0, 1); // 0.4
```
## Usage: Advanced
Keyboard-driven step with custom fine factor and detents:
```ts
import {
keyboardStep,
resolveSliderValue,
} from "./slider-helpers.js";
const proposed = keyboardStep({
key: event.key,
value: current,
min: -60,
max: 12,
step: 0.5,
fineFactor: 0.1,
shiftKey: event.shiftKey,
orientation: "vertical",
});
if (proposed !== null) {
const snapped = resolveSliderValue({
raw: proposed,
min: -60,
max: 12,
step: 0.5,
detents: [{ at: 0, label: "0 dB" }],
snapThreshold: 0.5,
});
onChange(snapped);
}
```
## API Reference
| Function | Signature |
| -------------------- | ------------------------------------------------------------------------------ |
| `clamp` | `(value, min, max) => number` |
| `quantize` | `(value, step, min) => number` |
| `applyDetents` | `(value, detents, threshold) => number` |
| `resolveSliderValue` | `({ raw, min, max, step, detents?, snapThreshold? }) => number` |
| `fractionForValue` | `(value, min, max) => number` (always clamped to 0..1) |
| `keyboardStep` | `(args) => number \| null`; returns `null` for unrecognised keys. |
## Implementation Notes
`applyDetents` greedily picks the closest detent within `threshold`
distance; `threshold` defaults to 2% of the span when not supplied.
`quantize` short-circuits when `step <= 0` so consumers can opt out
of stepping with `step: 0`. `keyboardStep` derives a perceptible
per-key delta of `max(step, span / 100)` so wide ranges still respond
to a single arrow key even when `step` is tiny. The orientation flip
between vertical / horizontal is encoded purely in the key mapping;
all downstream math is direction-agnostic.

View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
applyDetents,
clamp,
fractionForValue,
keyboardStep,
quantize,
resolveSliderValue,
} from "./slider-helpers.js";
describe("slider-helpers", () => {
it("clamps below min and above max", { timeout: 5000 }, () => {
expect(clamp(-1, 0, 1)).toBe(0);
expect(clamp(2, 0, 1)).toBe(1);
expect(clamp(0.5, 0, 1)).toBe(0.5);
});
it("quantize snaps to step", { timeout: 5000 }, () => {
expect(quantize(0.42, 0.1, 0)).toBeCloseTo(0.4, 5);
expect(quantize(0.48, 0.1, 0)).toBeCloseTo(0.5, 5);
});
it("quantize is identity when step <= 0", { timeout: 5000 }, () => {
expect(quantize(0.42, 0, 0)).toBe(0.42);
expect(quantize(0.42, -1, 0)).toBe(0.42);
});
it("applyDetents snaps within threshold", { timeout: 5000 }, () => {
const snapped = applyDetents(0.51, [{ at: 0.5 }], 0.05);
expect(snapped).toBe(0.5);
});
it("applyDetents ignores out-of-threshold detents", {
timeout: 5000,
}, () => {
expect(applyDetents(0.7, [{ at: 0.5 }], 0.05)).toBe(0.7);
});
it("resolveSliderValue runs full pipeline", { timeout: 5000 }, () => {
const v = resolveSliderValue({
raw: 0.51,
min: 0,
max: 1,
step: 0.01,
detents: [{ at: 0.5 }],
});
expect(v).toBe(0.5);
});
it("resolveSliderValue with no detents skips snap", {
timeout: 5000,
}, () => {
expect(
resolveSliderValue({ raw: 0.42, min: 0, max: 1, step: 0.01 })
).toBeCloseTo(0.42, 5);
});
it("resolveSliderValue uses default snapThreshold", {
timeout: 5000,
}, () => {
const v = resolveSliderValue({
raw: 0.505,
min: 0,
max: 1,
step: 0.001,
detents: [{ at: 0.5 }],
});
expect(v).toBe(0.5);
});
it("fractionForValue normalizes", { timeout: 5000 }, () => {
expect(fractionForValue(0, 0, 1)).toBe(0);
expect(fractionForValue(0.5, 0, 1)).toBeCloseTo(0.5, 5);
expect(fractionForValue(1, 0, 1)).toBe(1);
});
it("fractionForValue handles degenerate span", { timeout: 5000 }, () => {
expect(fractionForValue(5, 5, 5)).toBe(0);
});
it("fractionForValue clamps out-of-range", { timeout: 5000 }, () => {
expect(fractionForValue(2, 0, 1)).toBe(1);
expect(fractionForValue(-1, 0, 1)).toBe(0);
});
it("keyboardStep ArrowUp/ArrowDown for vertical", {
timeout: 5000,
}, () => {
const args = {
value: 0.5,
min: 0,
max: 1,
step: 0.1,
fineFactor: 0.1,
shiftKey: false,
orientation: "vertical" as const,
};
expect(keyboardStep({ ...args, key: "ArrowUp" })).toBeGreaterThan(0.5);
expect(keyboardStep({ ...args, key: "ArrowDown" })).toBeLessThan(0.5);
});
it("keyboardStep ArrowLeft/ArrowRight for horizontal", {
timeout: 5000,
}, () => {
const args = {
value: 0.5,
min: 0,
max: 1,
step: 0.1,
fineFactor: 0.1,
shiftKey: false,
orientation: "horizontal" as const,
};
expect(keyboardStep({ ...args, key: "ArrowRight" })).toBeGreaterThan(
0.5
);
expect(keyboardStep({ ...args, key: "ArrowLeft" })).toBeLessThan(0.5);
});
it("keyboardStep Home/End", { timeout: 5000 }, () => {
const args = {
value: 0.5,
min: 0,
max: 1,
step: 0.1,
fineFactor: 0.1,
shiftKey: false,
orientation: "vertical" as const,
};
expect(keyboardStep({ ...args, key: "Home" })).toBe(0);
expect(keyboardStep({ ...args, key: "End" })).toBe(1);
});
it("keyboardStep PageUp/PageDown", { timeout: 5000 }, () => {
const args = {
value: 0.5,
min: 0,
max: 1,
step: 0.1,
fineFactor: 0.1,
shiftKey: false,
orientation: "vertical" as const,
};
expect(keyboardStep({ ...args, key: "PageUp" })).toBeCloseTo(0.6, 5);
expect(keyboardStep({ ...args, key: "PageDown" })).toBeCloseTo(0.4, 5);
});
it("keyboardStep returns null for unknown keys", { timeout: 5000 }, () => {
expect(
keyboardStep({
key: "Tab",
value: 0.5,
min: 0,
max: 1,
step: 0.1,
fineFactor: 0.1,
shiftKey: false,
orientation: "vertical",
})
).toBeNull();
});
it("keyboardStep applies fineFactor on shift", { timeout: 5000 }, () => {
const normal = keyboardStep({
key: "ArrowUp",
value: 0.5,
min: 0,
max: 1,
step: 0.1,
fineFactor: 0.1,
shiftKey: false,
orientation: "vertical",
}) as number;
const fine = keyboardStep({
key: "ArrowUp",
value: 0.5,
min: 0,
max: 1,
step: 0.1,
fineFactor: 0.1,
shiftKey: true,
orientation: "vertical",
}) as number;
expect(fine - 0.5).toBeLessThan(normal - 0.5);
});
});

View File

@@ -0,0 +1,104 @@
/**
* Module-private slider helpers shared by `<SliderV>` and `<SliderH>`.
* Lives under `slider-v/` for stable physical location; `<SliderH>`
* imports cross-feature with a single relative path. Not exported
* from the package root.
*/
export interface SliderDetent {
readonly at: number;
readonly label?: string;
}
export interface SliderResolveArgs {
readonly raw: number;
readonly min: number;
readonly max: number;
readonly step: number;
readonly detents?: ReadonlyArray<SliderDetent> | undefined;
readonly snapThreshold?: number | undefined;
}
export function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
export function quantize(value: number, step: number, min: number): number {
if (step <= 0) return value;
const offset = value - min;
const snapped = Math.round(offset / step) * step;
return min + snapped;
}
export function applyDetents(
value: number,
detents: ReadonlyArray<SliderDetent>,
threshold: number
): number {
let bestDist = threshold;
let snapped = value;
for (const d of detents) {
const dist = Math.abs(d.at - value);
if (dist < bestDist) {
bestDist = dist;
snapped = d.at;
}
}
return snapped;
}
export function resolveSliderValue(args: SliderResolveArgs): number {
const { raw, min, max, step, detents, snapThreshold } = args;
const clamped = clamp(raw, min, max);
const stepped = quantize(clamped, step, min);
if (detents !== undefined && detents.length > 0) {
const threshold = snapThreshold ?? (max - min) * 0.02;
return applyDetents(stepped, detents, threshold);
}
return stepped;
}
export function fractionForValue(
value: number,
min: number,
max: number
): number {
if (max === min) return 0;
const f = (value - min) / (max - min);
return clamp(f, 0, 1);
}
export interface KeyboardStepArgs {
readonly key: string;
readonly value: number;
readonly min: number;
readonly max: number;
readonly step: number;
readonly fineFactor: number;
readonly shiftKey: boolean;
readonly orientation: "horizontal" | "vertical";
}
/**
* Returns the next value for a keyboard event, or `null` if the key
* is not a recognised slider key. Caller is responsible for clamping
* + quantization + detent snap via `resolveSliderValue`.
*/
export function keyboardStep(args: KeyboardStepArgs): number | null {
const { key, value, min, max, step, fineFactor, shiftKey, orientation } =
args;
const span = max - min;
const factor = shiftKey ? fineFactor : 1;
const delta = Math.max(step, span / 100) * factor;
const up = orientation === "vertical" ? "ArrowUp" : "ArrowRight";
const down = orientation === "vertical" ? "ArrowDown" : "ArrowLeft";
if (key === up) return value + delta;
if (key === down) return value - delta;
if (key === "Home") return min;
if (key === "End") return max;
if (key === "PageUp") return value + span * 0.1;
if (key === "PageDown") return value - span * 0.1;
return null;
}

View File

@@ -0,0 +1,109 @@
# `<SliderV>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Vertical slider control. Pointer interaction sets the value directly
from `clientY` against the rendered track rect — drag is a single
continuous gesture from `pointerdown` through `pointermove` until
`pointerup` / `pointercancel`. Keyboard handling delegates to the
shared `keyboardStep` helper so the up/down behaviour stays in lockstep
with `<SliderH>` (which uses left/right). Detents snap nearby values
to caller-provided breakpoints, useful for mix-engineering faders
that want a hard 0 dB stop.
## Features
- Pointer capture for jitter-free drag past the SVG bounds.
- Optional detent snapping with a per-control or default threshold.
- Keyboard accessibility: arrow keys, Home, End, PageUp, PageDown,
Shift fine-mode.
- `aria-slider` with `aria-orientation="vertical"`.
- CSS-variable theming (`--cs-slider-track-color`,
`--cs-slider-thumb-color`, `--cs-slider-detent-color`).
- Auto-rendered detent ticks on the track.
- Step-quantized output via `step` (default `0.01`).
## Usage: Basic
```tsx
import { useState } from "react";
import { SliderV } from "@suaveplan/react-control-surface";
export function VolumeSlider() {
const [v, setV] = useState(0.5);
return (
<SliderV
value={v}
onChange={setV}
min={0}
max={1}
step={0.01}
label="Volume"
/>
);
}
```
## Usage: Advanced
Mixer-style fader in dB with detents at -∞ / -20 / 0:
```tsx
import { useState } from "react";
import { SliderV } from "@suaveplan/react-control-surface";
export function ChannelFader() {
const [db, setDb] = useState(0);
return (
<SliderV
value={db}
onChange={setDb}
min={-60}
max={12}
step={0.5}
label="Channel 1"
detents={[
{ at: -60, label: "-∞" },
{ at: -20, label: "-20" },
{ at: 0, label: "0 dB" },
]}
snapThreshold={0.5}
trackPx={240}
thicknessPx={32}
/>
);
}
```
## API Reference
| Prop | Type | Default |
| ---------------- | --------------------- | -------- |
| `value` | `number` | required |
| `onChange` | `(v: number) => void` | required |
| `min` | `number` | required |
| `max` | `number` | required |
| `step` | `number` | `0.01` |
| `disabled` | `boolean` | `false` |
| `label` | `string` | — |
| `ariaLabel` | `string` | — |
| `detents` | `SliderDetent[]` | — |
| `snapThreshold` | `number` | 2% span |
| `trackPx` | `number` | `160` |
| `thicknessPx` | `number` | `24` |
| `fineModeFactor` | `number` | `0.1` |
| `className` | `string` | — |
## Implementation Notes
The slider derives value from the rendered SVG rect (not the bare
`trackPx`) so it remains accurate when the host container resizes it
via CSS. The internal helper module `slider-helpers.ts` is shared
verbatim with `<SliderH>`; the orientation flip lives entirely inside
`keyboardStep` and the visual rendering. When the rect height
collapses to 0 (offscreen / display:none), the math falls back to
`trackPx` so drag interactions continue to produce a sensible value.

View File

@@ -0,0 +1,89 @@
import { useState } from "react";
import { SliderV } from "./slider-v.js";
const meta = { title: "react-control-surface/SliderV", component: SliderV };
export default meta;
export const Default = {
render: () => {
function Demo() {
const [v, setV] = useState(0.5);
return (
<SliderV
value={v}
onChange={setV}
min={0}
max={1}
step={0.01}
label="Volume"
/>
);
}
return <Demo />;
},
};
export const Playground = {
args: { min: 0, max: 1, step: 0.01, trackPx: 200, thicknessPx: 32 },
render: (args: {
min: number;
max: number;
step: number;
trackPx: number;
thicknessPx: number;
}) => {
function Demo() {
const [v, setV] = useState((args.min + args.max) / 2);
return (
<SliderV
value={v}
onChange={setV}
min={args.min}
max={args.max}
step={args.step}
trackPx={args.trackPx}
thicknessPx={args.thicknessPx}
label="Playground"
/>
);
}
return <Demo />;
},
};
export const WithDetents = {
render: () => {
function Demo() {
const [v, setV] = useState(0);
return (
<SliderV
value={v}
onChange={setV}
min={-60}
max={12}
step={0.5}
label="Fader (dB)"
detents={[
{ at: -60, label: "-∞" },
{ at: -20, label: "-20" },
{ at: 0, label: "0" },
]}
/>
);
}
return <Demo />;
},
};
export const Disabled = {
render: () => (
<SliderV
value={0.5}
onChange={() => undefined}
min={0}
max={1}
disabled
label="Disabled"
/>
),
};

View File

@@ -0,0 +1,265 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { SliderV } from "./slider-v.js";
afterEach(() => cleanup());
// happy-dom's getBoundingClientRect returns zeros by default; stub to
// match the configured trackPx so clientY math is deterministic.
function stubRect(el: Element, height = 100) {
Object.defineProperty(el, "getBoundingClientRect", {
value: () => ({
top: 0,
left: 0,
right: 24,
bottom: height,
width: 24,
height,
x: 0,
y: 0,
toJSON: () => ({}),
}),
configurable: true,
});
(el as HTMLElement).setPointerCapture = vi.fn();
(el as HTMLElement).releasePointerCapture = vi.fn();
(el as HTMLElement).hasPointerCapture = vi.fn(() => true);
}
function setup(overrides: Partial<Parameters<typeof SliderV>[0]> = {}) {
const onChange = vi.fn();
const utils = render(
<SliderV
value={0.5}
min={0}
max={1}
step={0.01}
label="Vol"
onChange={onChange}
trackPx={100}
{...overrides}
/>
);
const el = within(utils.container).getByRole("slider") as HTMLDivElement;
stubRect(el);
return { onChange, el, ...utils };
}
describe("<SliderV>", () => {
it("renders with aria attributes", { timeout: 5000 }, () => {
const { el } = setup();
expect(el.getAttribute("role")).toBe("slider");
expect(el.getAttribute("aria-orientation")).toBe("vertical");
expect(el.getAttribute("aria-valuemin")).toBe("0");
expect(el.getAttribute("aria-valuemax")).toBe("1");
expect(el.getAttribute("aria-valuenow")).toBe("0.5");
expect(el.getAttribute("aria-label")).toBe("Vol");
});
it("uses default aria-label", { timeout: 5000 }, () => {
render(
<SliderV value={0} min={0} max={1} onChange={() => undefined} />
);
expect(screen.getByRole("slider").getAttribute("aria-label")).toBe(
"Slider"
);
});
it("uses ariaLabel override", { timeout: 5000 }, () => {
const { el } = setup({ ariaLabel: "X" });
expect(el.getAttribute("aria-label")).toBe("X");
});
it("is focusable when enabled / non-focusable when disabled", {
timeout: 5000,
}, () => {
const a = setup();
expect(a.el.getAttribute("tabindex")).toBe("0");
const b = setup({ disabled: true });
expect(b.el.getAttribute("tabindex")).toBe("-1");
});
it("emits onChange on ArrowUp", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0.5, step: 0.1 });
fireEvent.keyDown(el, { key: "ArrowUp" });
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls[0][0]).toBeGreaterThan(0.5);
});
it("ignores keys when disabled", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true });
fireEvent.keyDown(el, { key: "ArrowUp" });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores unknown keys", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.keyDown(el, { key: "Tab" });
expect(onChange).not.toHaveBeenCalled();
});
it("pointer-down at top → max", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerDown(el, { pointerId: 1, clientY: 0 });
expect(onChange).toHaveBeenLastCalledWith(1);
});
it("pointer-down at bottom → min", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
expect(onChange).toHaveBeenLastCalledWith(0);
});
it("pointer-move while drag active emits", { timeout: 5000 }, () => {
const { el, onChange } = setup({ value: 0 });
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerMove(el, { pointerId: 1, clientY: 50 });
expect(onChange).toHaveBeenCalledWith(0.5);
});
it("pointer-move with no active drag is a no-op", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerMove(el, { pointerId: 1, clientY: 50 });
expect(onChange).not.toHaveBeenCalled();
});
it("ignores pointerDown when disabled", { timeout: 5000 }, () => {
const { el, onChange } = setup({ disabled: true });
fireEvent.pointerDown(el, { pointerId: 1, clientY: 0 });
expect(onChange).not.toHaveBeenCalled();
});
it("pointer cancel releases drag", { timeout: 5000 }, () => {
const { el, onChange } = setup();
fireEvent.pointerDown(el, { pointerId: 1, clientY: 100 });
fireEvent.pointerCancel(el, { pointerId: 1 });
// After cancel, a subsequent move should NOT emit
const callsBefore = onChange.mock.calls.length;
fireEvent.pointerMove(el, { pointerId: 1, clientY: 50 });
expect(onChange.mock.calls.length).toBe(callsBefore);
});
it("snaps to detent within threshold", { timeout: 5000 }, () => {
const { el, onChange } = setup({
value: 0,
detents: [{ at: 0.5, label: "mid" }],
step: 0.001,
snapThreshold: 0.05,
});
// clientY of ~50 with 100px tall → t ~ 0.5; should snap to 0.5
fireEvent.pointerDown(el, { pointerId: 1, clientY: 49 });
expect(onChange).toHaveBeenCalledWith(0.5);
});
it("renders detent marker DOM nodes", { timeout: 5000 }, () => {
const { container } = render(
<SliderV
value={0.5}
min={0}
max={1}
onChange={() => undefined}
detents={[{ at: 0 }, { at: 0.5 }, { at: 1 }]}
trackPx={100}
/>
);
const markers = container.querySelectorAll('[data-detent="true"]');
expect(markers.length).toBe(3);
});
it("clamps thumb to track when value === min", { timeout: 5000 }, () => {
const { container } = render(
<SliderV
value={0}
min={0}
max={1}
onChange={() => undefined}
trackPx={100}
/>
);
const thumb = container.querySelector(
'[data-role="thumb"]'
) as SVGRectElement;
expect(thumb).not.toBeNull();
});
it("handles degenerate rect (height 0 → falls back to trackPx)", {
timeout: 5000,
}, () => {
const onChange = vi.fn();
render(
<SliderV
value={0}
min={0}
max={1}
onChange={onChange}
trackPx={100}
/>
);
const el = screen.getByRole("slider");
Object.defineProperty(el, "getBoundingClientRect", {
value: () => ({
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}),
configurable: true,
});
(el as HTMLElement).setPointerCapture = vi.fn();
(el as HTMLElement).releasePointerCapture = vi.fn();
(el as HTMLElement).hasPointerCapture = vi.fn(() => true);
fireEvent.pointerDown(el, { pointerId: 1, clientY: 50 });
expect(onChange).toHaveBeenCalled();
});
it("uses default step when step is undefined", { timeout: 5000 }, () => {
const onChange = vi.fn();
render(<SliderV value={0.5} min={0} max={1} onChange={onChange} />);
const el = screen.getByRole("slider");
fireEvent.keyDown(el, { key: "ArrowUp" });
expect(onChange).toHaveBeenCalled();
});
it("skips onChange when snapped value equals current", {
timeout: 5000,
}, () => {
const onChange = vi.fn();
render(
<SliderV
value={1}
min={0}
max={1}
step={1}
onChange={onChange}
trackPx={100}
/>
);
const el = screen.getByRole("slider");
stubRect(el);
// clientY=10 → t=0.9 → raw=0.9 → quantize(0.9,1,0)=1 (==value), skip
fireEvent.pointerDown(el, { pointerId: 1, clientY: 10 });
expect(onChange).not.toHaveBeenCalled();
});
it("pointerUp without prior pointerDown is a no-op", {
timeout: 5000,
}, () => {
const { el } = setup();
const release = (el as HTMLElement).releasePointerCapture as ReturnType<
typeof vi.fn
>;
fireEvent.pointerUp(el, { pointerId: 1, clientY: 50 });
expect(release).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,192 @@
import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
} from "react";
import {
fractionForValue,
keyboardStep,
resolveSliderValue,
} from "./slider-helpers.js";
import type { SliderVProps } from "./slider-v.types.js";
type DivPointerEvent = PointerEvent<HTMLDivElement>;
type DivKeyboardEvent = KeyboardEvent<HTMLDivElement>;
const DEFAULT_TRACK_PX = 160;
const DEFAULT_THICKNESS_PX = 24;
const DEFAULT_STEP = 0.01;
const DEFAULT_FINE_FACTOR = 0.1;
/**
* Vertical slider with optional detents. Reports its range via
* standard `aria-valuemin/max/now` attributes and behaves as a
* `role="slider"` with `aria-orientation="vertical"`.
*/
export function SliderV(props: SliderVProps): ReactNode {
const {
value,
onChange,
min,
max,
step = DEFAULT_STEP,
disabled,
label,
ariaLabel,
detents,
snapThreshold,
trackPx = DEFAULT_TRACK_PX,
thicknessPx = DEFAULT_THICKNESS_PX,
fineModeFactor = DEFAULT_FINE_FACTOR,
className,
} = props;
const dragRef = useRef<{ pointerId: number } | null>(null);
const emit = useCallback(
(raw: number) => {
const next = resolveSliderValue({
raw,
min,
max,
step,
detents,
snapThreshold,
});
if (next !== value) onChange(next);
},
[min, max, step, detents, snapThreshold, value, onChange]
);
const valueFromClientY = useCallback(
(event: DivPointerEvent) => {
const rect = event.currentTarget.getBoundingClientRect();
const offsetY = event.clientY - rect.top;
const usable = rect.height === 0 ? trackPx : rect.height;
const t = 1 - offsetY / usable;
return min + Math.max(0, Math.min(1, t)) * (max - min);
},
[min, max, trackPx]
);
const onPointerDown = useCallback(
(event: DivPointerEvent) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { pointerId: event.pointerId };
emit(valueFromClientY(event));
},
[disabled, emit, valueFromClientY]
);
const onPointerMove = useCallback(
(event: DivPointerEvent) => {
if (dragRef.current === null) return;
emit(valueFromClientY(event));
},
[emit, valueFromClientY]
);
const onPointerUp = useCallback((event: DivPointerEvent) => {
const drag = dragRef.current;
if (drag === null) return;
// happy-dom returns false from hasPointerCapture for any id —
// releasing unconditionally is safe (the DOM ignores the call
// when no capture exists) and keeps branch coverage clean.
event.currentTarget.releasePointerCapture(drag.pointerId);
dragRef.current = null;
}, []);
const onKeyDown = useCallback(
(event: DivKeyboardEvent) => {
if (disabled) return;
const next = keyboardStep({
key: event.key,
value,
min,
max,
step,
fineFactor: fineModeFactor,
shiftKey: event.shiftKey,
orientation: "vertical",
});
if (next === null) return;
event.preventDefault();
emit(next);
},
[disabled, value, min, max, step, fineModeFactor, emit]
);
const f = fractionForValue(value, min, max);
const thumbCenterY = trackPx - f * trackPx;
const thumbHeight = 10;
const half = thicknessPx / 2;
return (
<div
className={className}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
aria-label={ariaLabel ?? label ?? "Slider"}
aria-orientation="vertical"
aria-disabled={disabled ? "true" : "false"}
data-orientation="vertical"
style={{
display: "inline-block",
width: thicknessPx,
height: trackPx,
touchAction: "none",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
>
<svg
width={thicknessPx}
height={trackPx}
viewBox={`0 0 ${thicknessPx} ${trackPx}`}
aria-hidden="true"
focusable="false"
>
<rect
x={half - 2}
y={0}
width={4}
height={trackPx}
fill="var(--cs-slider-track-color, #444)"
/>
{detents?.map((d) => {
const dy =
trackPx - fractionForValue(d.at, min, max) * trackPx;
return (
<line
key={`detent-${d.at}`}
x1={0}
y1={dy}
x2={thicknessPx}
y2={dy}
stroke="var(--cs-slider-detent-color, #888)"
strokeWidth={1}
data-detent="true"
/>
);
})}
<rect
x={0}
y={Math.max(0, thumbCenterY - thumbHeight / 2)}
width={thicknessPx}
height={thumbHeight}
fill="var(--cs-slider-thumb-color, #fff)"
data-role="thumb"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { BoundControlProps } from "../bound-control.types.js";
import type { SliderDetent } from "./slider-helpers.js";
export type { SliderDetent } from "./slider-helpers.js";
export interface SliderVProps extends BoundControlProps {
/** Optional detents (snap-points) along the slider track. */
readonly detents?: ReadonlyArray<SliderDetent>;
/** Detent snap radius in value-units. Default: 2% of (max - min). */
readonly snapThreshold?: number;
/** Track height in pixels. Default: 160. */
readonly trackPx?: number;
/** Track width in pixels. Default: 24. */
readonly thicknessPx?: number;
/** Fine-mode multiplier applied while Shift is held. Default: 0.1. */
readonly fineModeFactor?: number;
/** Optional `className` forwarded to the root SVG. */
readonly className?: string;
}

View File

@@ -0,0 +1,2 @@
export { isSwitch2Checked, Switch2 } from "./switch-2.js";
export type { Switch2Props } from "./switch-2.types.js";

View File

@@ -0,0 +1,69 @@
# `<Switch2>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<Switch2>` is the two-position toggle control. It implements the WAI-ARIA
"switch" pattern (`role="switch"` + `aria-checked`) and accepts user input
from pointer clicks, Space, and Enter. The numeric `value` is mapped into
a boolean via a midpoint threshold; toggling emits the schema's `min`
(off) or `max` (on) into `onChange`.
The component is purely controlled — it never holds local state for the
checked value. Consumers wire the value back through their state store or
the binding registry, which keeps the control flow predictable when the
same switch is driven by both UI interaction and external MIDI input.
## Features
- WAI-ARIA "switch" role with `aria-checked` reflection.
- Keyboard activation via Space and Enter.
- Pointer activation via click.
- Disabled state via `disabled` prop (also writes `aria-disabled`).
- Telemetry: increments `control_surface_interactions_total{kind="switch-2"}`
on every toggle.
- Pure `isSwitch2Checked` helper for unit testing the threshold.
## Usage: Basic
```tsx
<Switch2
value={muted ? 1 : 0}
onChange={(v) => setMuted(v === 1)}
min={0}
max={1}
label="Mute"
/>
```
## Usage: Advanced
```tsx
<Switch2
value={state}
onChange={(v) => registry.dispatch(binding, v)}
min={0}
max={1}
label="Bypass"
ariaLabel="Bypass effect — Cmd+B"
disabled={readOnly}
className="bypass-switch"
/>
```
## API Reference
- `<Switch2>` props: `BoundControlProps & { className?, style? }`.
- `isSwitch2Checked(value, min, max)` — pure threshold helper.
## Implementation Notes
The component renders as a `<button type="button">` instead of an
`<input type="checkbox">` so the WAI-ARIA "switch" semantics dominate the
native checkbox semantics that some screen readers still announce
inconsistently. The implementation explicitly handles Space + Enter
because native buttons activate on Space but not Enter on some Linux
distributions; the explicit handler unifies behaviour across platforms.

View File

@@ -0,0 +1,24 @@
/* istanbul ignore file -- story-only fixtures */
import type { Meta, StoryObj } from "@storybook/react";
import { Switch2 } from "./switch-2.js";
const meta = {
title: "react-control-surface/controls/Switch2",
component: Switch2,
} satisfies Meta<typeof Switch2>;
export default meta;
type Story = StoryObj<typeof meta>;
const base = { onChange: () => undefined, min: 0, max: 1 };
export const Default: Story = {
args: { ...base, value: 0, label: "Mute" },
};
export const On: Story = { args: { ...base, value: 1, label: "Mute" } };
export const Disabled: Story = {
args: { ...base, value: 0, label: "Mute", disabled: true },
};
export const Playground: Story = {
args: { ...base, value: 0.5, label: "Toggle" },
};

View File

@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from "@suaveplan/testing/runner";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { isSwitch2Checked, Switch2 } from "./switch-2.js";
const base = { min: 0, max: 1 } as const;
afterEach(() => cleanup());
describe("<Switch2>", () => {
it("renders unchecked when value below midpoint", { timeout: 5000 }, () => {
const spy = vi.fn();
render(<Switch2 {...base} value={0} onChange={spy} label="Mute" />);
const btn = screen.getByRole("switch", { name: "Mute" });
expect(btn.getAttribute("aria-checked")).toBe("false");
});
it("emits max on click when off", { timeout: 5000 }, () => {
const spy = vi.fn();
render(<Switch2 {...base} value={0} onChange={spy} label="Mute" />);
fireEvent.click(screen.getByRole("switch", { name: "Mute" }));
expect(spy).toHaveBeenCalledWith(1);
});
it("emits min on click when on", { timeout: 5000 }, () => {
const spy = vi.fn();
render(<Switch2 {...base} value={1} onChange={spy} label="Mute" />);
fireEvent.click(screen.getByRole("switch", { name: "Mute" }));
expect(spy).toHaveBeenCalledWith(0);
});
it("responds to Space and Enter keys", { timeout: 5000 }, () => {
const spy = vi.fn();
render(<Switch2 {...base} value={0} onChange={spy} label="Mute" />);
const btn = screen.getByRole("switch", { name: "Mute" });
fireEvent.keyDown(btn, { key: " " });
fireEvent.keyDown(btn, { key: "Enter" });
expect(spy).toHaveBeenCalledTimes(2);
});
it("ignores other keys", { timeout: 5000 }, () => {
const spy = vi.fn();
render(<Switch2 {...base} value={0} onChange={spy} label="Mute" />);
fireEvent.keyDown(screen.getByRole("switch", { name: "Mute" }), {
key: "Tab",
});
expect(spy).not.toHaveBeenCalled();
});
it("respects disabled and falls back aria-label", { timeout: 5000 }, () => {
const spy = vi.fn();
render(
<Switch2
{...base}
value={0}
onChange={spy}
disabled
ariaLabel="Mute override"
className="big"
style={{ color: "red" }}
/>
);
const btn = screen.getByRole("switch", {
name: "Mute override",
});
fireEvent.click(btn);
expect(spy).not.toHaveBeenCalled();
expect(btn.className).toBe("rcs-switch-2 big");
expect(btn.style.color).toBe("red");
});
it("falls back to default aria-label when no label/ariaLabel", {
timeout: 5000,
}, () => {
render(<Switch2 {...base} value={0} onChange={() => undefined} />);
expect(screen.getByRole("switch", { name: "switch" })).toBeDefined();
});
it("isSwitch2Checked obeys midpoint threshold", { timeout: 5000 }, () => {
expect(isSwitch2Checked(0.5, 0, 1)).toBe(true);
expect(isSwitch2Checked(0.49, 0, 1)).toBe(false);
});
});

View File

@@ -0,0 +1,78 @@
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
} from "react";
import { recordInteraction } from "../../telemetry/telemetry.js";
import type { Switch2Props } from "./switch-2.types.js";
/** Maps the numeric value into the boolean checked state. */
export function isSwitch2Checked(
value: number,
min: number,
max: number
): boolean {
return value >= (min + max) / 2;
}
/**
* Two-position toggle implemented as `role="switch"`. The numeric value
* maps onto on/off via a midpoint threshold; toggling emits `min` or
* `max` as the next value. Space / Enter activate the switch from the
* keyboard; pointer click activates it from a mouse / touch.
*/
export function Switch2(props: Switch2Props): ReactNode {
const {
value,
onChange,
min,
max,
label,
ariaLabel,
disabled,
className,
style,
} = props;
const checked = isSwitch2Checked(value, min, max);
const toggle = useCallback(() => {
if (disabled === true) return;
const next = checked ? min : max;
recordInteraction("switch-2");
onChange(next);
}, [checked, disabled, max, min, onChange]);
const onKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
toggle();
}
},
[toggle]
);
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel ?? label ?? "switch"}
aria-disabled={disabled === true ? true : undefined}
data-kind="switch-2"
data-checked={checked ? "true" : "false"}
className={
className === undefined
? "rcs-switch-2"
: `rcs-switch-2 ${className}`
}
style={style}
onClick={toggle}
onKeyDown={onKeyDown}
>
{label === undefined ? null : (
<span aria-hidden="true">{label}</span>
)}
</button>
);
}

View File

@@ -0,0 +1,7 @@
import type { CSSProperties } from "react";
import type { BoundControlProps } from "../bound-control/bound-control.types.js";
export interface Switch2Props extends BoundControlProps {
readonly className?: string;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,6 @@
export {
Switch3,
switch3Position,
switch3ValueForPosition,
} from "./switch-3.js";
export type { Switch3Props } from "./switch-3.types.js";

View File

@@ -0,0 +1,72 @@
# `<Switch3>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<Switch3>` is the three-position toggle, modelling the kind of
"down-centre-up" or "low-mid-high" switch found on outboard audio gear.
Click cycles forward (off -> mid -> on -> off), keyboard arrows step
stepwise between adjacent positions, and Space / Enter cycle in the same
direction as a click. The numeric value is mapped onto positions 0/1/2
using a third-band threshold; the emitted `onChange` values are `min`,
`(min+max)/2`, and `max`.
The component renders as a `<button role="slider">` with
`aria-valuemin=0`, `aria-valuemax=2`, `aria-valuenow=position`, and
`aria-valuetext` matching the human-readable position name. This keeps
the switch operable from a screen reader while still allowing click-to-
cycle interaction for sighted users.
## Features
- Click + Space/Enter cycle forward.
- Arrow keys step (Right/Up forwards, Left/Down backwards), clamped at
the ends.
- Customisable position labels via `positionLabels`.
- Telemetry: increments `control_surface_interactions_total{kind="switch-3"}`
per change.
- Pure helpers `switch3Position` and `switch3ValueForPosition`.
## Usage: Basic
```tsx
<Switch3
value={routing}
onChange={setRouting}
min={0}
max={1}
label="Routing"
/>
```
## Usage: Advanced
```tsx
<Switch3
value={state}
onChange={(v) => registry.dispatch(binding, v)}
min={-1}
max={1}
label="Polarity"
positionLabels={["Inverted", "Bypass", "Normal"]}
disabled={readOnly}
/>
```
## API Reference
- `<Switch3>` props: `BoundControlProps & { className?, style?, positionLabels? }`.
- `switch3Position(value, min, max)` — pure helper returning 0/1/2.
- `switch3ValueForPosition(position, min, max)` — inverse helper.
## Implementation Notes
The component uses `role="slider"` rather than three native radio inputs
because that better matches the visual model of a single-button cycling
toggle while still meeting WCAG keyboard requirements. The third-band
threshold for `switch3Position` uses inclusive `<=` at the lower edge and
exclusive `<` at the upper edge to ensure that a value of exactly `min`
maps to position 0 (off) and not to position 1.

Some files were not shown because too many files have changed in this diff Show More