Initial Commit
This commit is contained in:
114
packages/ui/react/react-control-surface/README.md
Normal file
114
packages/ui/react/react-control-surface/README.md
Normal 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).
|
||||
|
||||
145
packages/ui/react/react-control-surface/package.json
Normal file
145
packages/ui/react/react-control-surface/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { BoundControlProps } from "./bound-control.types.js";
|
||||
@@ -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.
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ButtonLatching,
|
||||
isButtonLatchingOn,
|
||||
} from "./button-latching.js";
|
||||
export type { ButtonLatchingProps } from "./button-latching.types.js";
|
||||
@@ -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.
|
||||
@@ -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" } };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ButtonMomentary } from "./button-momentary.js";
|
||||
export type { ButtonMomentaryProps } from "./button-momentary.types.js";
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DigitalDisplay } from "./digital-display.js";
|
||||
export type {
|
||||
DigitalDisplayMode,
|
||||
DigitalDisplayProps,
|
||||
} from "./digital-display.types.js";
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DrumPad } from "./drum-pad.js";
|
||||
export type { DrumPadProps } from "./drum-pad.types.js";
|
||||
@@ -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.
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Image } from "./image.js";
|
||||
export type { ImageProps } from "./image.types.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { decayVelocity, JogWheel } from "./jog-wheel.js";
|
||||
export type { JogWheelProps } from "./jog-wheel.types.js";
|
||||
@@ -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.
|
||||
@@ -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" />
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Knob } from "./knob.js";
|
||||
export type { KnobCurve, KnobCurvePoint, KnobProps } from "./knob.types.js";
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LED, ledBrightness } from "./led.js";
|
||||
export type { LEDProps } from "./led.types.js";
|
||||
@@ -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.
|
||||
@@ -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 } };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MeterPeak, stepPeakHold } from "./meter-peak.js";
|
||||
export type { MeterPeakProps } from "./meter-peak.types.js";
|
||||
@@ -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.
|
||||
@@ -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} />
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MeterSpectral } from "./meter-spectral.js";
|
||||
export type { MeterSpectralProps } from "./meter-spectral.types.js";
|
||||
@@ -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.
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MeterVU, stepBallistic } from "./meter-vu.js";
|
||||
export type { MeterVUProps } from "./meter-vu.types.js";
|
||||
@@ -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.
|
||||
@@ -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} />
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { PianoKey } from "./piano-key.js";
|
||||
export type {
|
||||
PianoKeyKind,
|
||||
PianoKeyProps,
|
||||
PianoKeyState,
|
||||
} from "./piano-key.types.js";
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SliderH } from "./slider-h.js";
|
||||
export type { SliderDetent, SliderHProps } from "./slider-h.types.js";
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SliderV } from "./slider-v.js";
|
||||
export type { SliderDetent, SliderVProps } from "./slider-v.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { isSwitch2Checked, Switch2 } from "./switch-2.js";
|
||||
export type { Switch2Props } from "./switch-2.types.js";
|
||||
@@ -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.
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
Switch3,
|
||||
switch3Position,
|
||||
switch3ValueForPosition,
|
||||
} from "./switch-3.js";
|
||||
export type { Switch3Props } from "./switch-3.types.js";
|
||||
@@ -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
Reference in New Issue
Block a user