Audit fixes: a11y, gesture correctness, math edge cases

- Keyboard focus-visible ring on all controls (WCAG 2.4.7), suppressed
  for pointer grabs; focusRing prop to customize or disable
- aria-orientation on track modes; data-dragging/-disabled/-readonly/
  -focus-visible state attributes; disabled dims, readOnly drops the
  grab cursor
- Escape cancels a drag and restores the pre-gesture value
- Controlled mode no longer desyncs when the parent rejects onChange
- Second concurrent pointer can no longer hijack a drag session;
  drags cancel when disabled/readOnly flips mid-gesture
- Wheel: onChangeStart symmetry, settle flush on unsubscribe, dominant-
  axis direction (fixes macOS Shift+scroll reversal), ignored mid-drag
- Shift+wheel and fine multipliers now work on stepped/values knobs
- Fader/LEDFader track mapping inset-aligned with handle travel (no
  value jump when grabbing the cap); LEDFader zones sorted
- roundTo no longer returns NaN for exponential-notation values;
  logTaper falls back to linear for invalid ranges; decimalsFromStep
  shaves float noise; findClosest([]) returns the input
- Double-click reset brackets with onChangeStart/End
- MetalKnob honors showValue/unit/format; VintageKnob omits them from
  its type; TickLabels extracted as a public primitive
- SegmentDisplay: letters/hex charset, dash overflow instead of 888
This commit is contained in:
Dreamodus 2026-07-12 14:38:50 -07:00
parent 60a02cb1db
commit da352d3695
13 changed files with 378 additions and 82 deletions

View file

@ -34,6 +34,9 @@ describe('decimalsFromStep', () => {
expect(decimalsFromStep(0)).toBe(3);
expect(decimalsFromStep(NaN)).toBe(3);
});
it('shaves float noise from computed steps', () => {
expect(decimalsFromStep(0.1 + 0.2)).toBe(1); // 0.30000000000000004 -> 1
});
});
describe('roundTo', () => {
@ -43,6 +46,16 @@ describe('roundTo', () => {
expect(roundTo(123.456789, 4)).toBe(123.4568);
expect(roundTo(5, 0)).toBe(5);
});
it('handles values that stringify exponentially', () => {
expect(roundTo(1e-8, 10)).toBe(1e-8);
expect(roundTo(2.5e-7, 7)).toBe(3e-7);
expect(roundTo(1.5e21, 0)).toBe(1.5e21);
expect(Number.isNaN(roundTo(1e-9, 3))).toBe(false);
});
it('passes non-finite values through', () => {
expect(roundTo(NaN, 2)).toBeNaN();
expect(roundTo(Infinity, 2)).toBe(Infinity);
});
});
describe('snapToStep', () => {
@ -61,6 +74,9 @@ describe('findClosest', () => {
expect(findClosest([1, 100, 5, 50], 42)).toBe(50);
expect(findClosest([0.25, 0.5, 1, 2, 4], 0.8)).toBe(1);
});
it('returns the input value for an empty list', () => {
expect(findClosest([], 5)).toBe(5);
});
});
describe('tapers', () => {
@ -74,6 +90,11 @@ describe('tapers', () => {
roundTo(Math.log(100) / Math.log(1000), 4),
);
});
it('log taper falls back to linear for invalid ranges instead of NaN', () => {
expect(logTaper.fromNormalized(0.5, 0, 100)).toBe(50);
expect(logTaper.toNormalized(0, -10, 10)).toBe(0.5);
expect(Number.isNaN(logTaper.fromNormalized(0.5, -10, 10))).toBe(false);
});
it('pow taper round-trips', () => {
const t = powTaper(2);
const n = t.toNormalized(t.fromNormalized(0.3, 0, 10), 0, 10);