メインコンテンツまでスキップ

Migrating through 0.x

tremolo-ui is still on 0.x, so breaking changes ship in minor releases. This page collects them in one place, newest first, with what to write instead.

Every release also has generated notes per package: @tremolo-ui/functions, @tremolo-ui/dom, @tremolo-ui/react. All three packages are released together and always share a version.

Unreleased

styleHelper is gone

It turned a number into a pixel length, with an optional bit of arithmetic:

styleHelper(10) // '10px'
styleHelper(10, '/', 2) // '5px'

Nothing in the package uses it any more. Its one caller was the border radius of Slider.Track, which is calc(var(--thickness) / 2) in CSS now that the track no longer paints itself.

Write the two cases directly:

typeof value === 'number' ? `${value}px` : value
`calc(${value} / 2)`

PointsEditor selects points

Points can be selected, and a selection moves as one. Turn it on with selectable — it is off by default, since it changes what a press and a drag mean, and an editor whose points each mean something different (the four handles of an ADSR envelope, say) has nothing to gain from moving them together.

<PointsEditor.Root selectable>
  • a press selects the point it landed on
  • ctrl or ⌘ adds to the selection — not shift, which is the fine-adjustment key on every control here and cannot be both
  • a drag on empty space draws a rubber band and selects what it covers; hold ctrl or ⌘ to add to what is already selected
  • dragging or arrow-keying one selected point moves the whole selection

The editor keeps the selection itself unless you take it over with selection / onSelectionChange. Points are named by their id prop, or by one generated to last as long as the point is mounted.

<PointsEditor.Root selectable selection={selected} onSelectionChange={setSelected}>

Update each point from the previous state. A selection calls onChange on several points in the same tick, so a handler that rebuilds its state from a value captured in the render keeps only the last one, and every point but one appears stuck:

// good
onChange={(v) => setPoints((prev) => ({ ...prev, [id]: v }))}
// throws away every call but the last
onChange={(v) => setPoints({ ...points, [id]: v })}

A selected point carries data-selected="true", and the rubber band is .tremolo-points-editor-marquee. Both are in the theme in Styling.

A drag now moves a point rather than putting it under the pointer. Grabbing a point at its edge used to shift it under the cursor on the first movement; it keeps the offset it was grabbed at now, which is what makes moving several at once mean anything. min and max still hold, and a selection stops as a whole when any one of its points reaches a limit — clamping each on its own would pull the selection out of shape.

Deleting and duplicating are not here: the points are yours, and only you know what the array behind them is.

Appearance props write custom properties

Every prop that decides what a component looks like now sets a custom property and nothing else. The default moved with it, from the component into the CSS.

<Slider.Track thickness={16} />
// is exactly
<Slider.Track style={{ '--thickness': '16px' }} />

What changes for you. These props used to be written into the element as inline styles, which a stylesheet could not override without !important. Now they are the same cascade as everything else, and a rule of yours is enough to change the default everywhere:

.tremolo-slider-track {
--thickness: 16px;
}

The properties are listed in Styling. A number is taken as pixels; a string is written through, so '3rem' and 'auto' work.

Slider.Track no longer paints itself, and defaultStyle is gone. The track publishes --percent — where the value sits, the one number CSS cannot work out — and the fill is a rule in the theme:

.tremolo-slider-track {
background: linear-gradient(
var(--axis),
var(--active) var(--percent),
var(--inactive) var(--percent)
);
}

That is what defaultStyle={false} was for, and there is nothing left to turn off: to draw the track yourself, write the rule you want instead of ours. The Slider.css in Styling is the starting point.

If you were not using our CSS at all, Slider.Track, XYPad.Area, PointsEditor, PointsEditor.Point and Piano no longer carry a size of their own, so give them one — either in your CSS or through the props, which now feed it.

Two new state attributes come with it: data-flipped on Slider.Track, for a value that grows from the far end, and data-fill on Piano.

Arrow keys leave an IME alone

An arrow key no longer steps the value of a NumberInput while an IME is converting. There it belongs to the candidate list, and stepping fought the conversion.

Nothing else changed about the arrow keys. NumberInput.InputField takes a new keepCaretOnStep if you want the caret held on its column while stepping; it is off by default.

Shift is the fine-adjustment key

Holding shift with an arrow key now moves the value by a tenth of a step, on every component. Nothing had to be written to get it, and nothing breaks if you were not expecting it — a plain press behaves exactly as before, and the next one snaps back onto the grid.

wheel and keyboard take a map if you want something else. A plain tuple, which is what they took before, opts out of modifiers entirely.

keyboard={{ default: ['raw', 1], alt: ['raw', 0.5] }}
keyboard={['raw', 1]}

A modifier entry is not snapped to step. Naming one says you want to move off the grid, and without that a finer amount would round straight back to where it started.

Nothing is bound on the wheel. Browsers turn shift+wheel into horizontal scrolling, so shift is not available there.

Shift makes a drag fine

Holding shift while dragging now moves the value a tenth as far, the same as it does on the arrow keys, on Knob, Slider, XYPad and PointsEditor. Pressing or releasing it partway through does not disturb the value.

dragSensitivity rebinds it, and a bare number uses no modifier at all.

<Slider.Root dragSensitivity={{ default: 1, alt: 0.25 }}/>
<Slider.Root dragSensitivity={1}/>

On Slider, XYPad and PointsEditor this changes what a drag is. Their value is normally the position pointed at, so a fine drag cannot stay under the pointer — it moves a tenth as fast, and the two drift apart. They stay apart when the key is released: pulling the value back under the pointer would move it by however far the two had drifted, which is a jump nobody asked for. Let go and grab again to line them up.

Knob is unaffected by that: it was already relative.

NumberInput.Stepper takes it too. There a drag is already relative, so the sensitivity simply divides the amount: drag pixels move a tenth of a step instead of a whole one.

<NumberInput.Root dragSensitivity={{ default: 1, shift: 0.1 }}/>

pointerLock hides the cursor while dragging

Knob and NumberInput.Stepper take pointerLock. With it on, the cursor disappears for the length of the drag and the pointer movement is read directly.

<Knob.Root pointerLock … />

The reason is not tidiness. A relative drag does not care where the pointer is, but it still stops at the edge of the screen: the operating system pins the pointer there and the coordinates stop changing, so the value stops moving however far you keep dragging. A fine drag — shift held, or a low dragSensitivity — reaches that edge quickly.

It is off by default. The browser shows a notice of its own, Esc takes the lock back, and the request can be refused; a refused request is not an error, and the drag carries on as an ordinary one. Losing the lock ends the drag, since no pointerup is coming.

Not available on Slider, XYPad or PointsEditor: their value is the position pointed at, and there is no position while the pointer is locked.

Shift and the wheel move XYPad and PointsEditor both ways

Shift+wheel could only ever raise the x value. The direction came from deltaY, which browsers empty once they move the scroll onto deltaX. Both components now read whichever axis moved, so scrolling back lowers the value.

A trackpad's own horizontal gesture moves x as well now, with no modifier held.

The package ships no CSS

@tremolo-ui/react/styles/index.css and every other ./styles/*.css export are removed, and dist/index.css is no longer built. Importing one now fails to resolve rather than silently doing nothing, so the break is visible at build time.

- import '@tremolo-ui/react/styles/index.css'
+ import './tremolo-theme.css'

Nothing about the markup changed: the same tremolo- class names and the same aria-* / data-* state attributes are still there, so a stylesheet that already overrides them keeps working. What is gone is the one underneath it.

To keep the current appearance, copy the theme from Styling — the six files that style every example in these docs — into your project and import that instead. It is plain CSS with no build step, and it is where customisation was heading anyway: previously the only way to change a default was to load dist/index.css and then out-specify it.

@tremolo-ui/react/styles/global.css is removed with them, along with the .tremolo-user-select-none and .tremolo-cursor-* classes it held. This one needs nothing done. The page-wide user-select: none applied during a drag is now an inline style the component sets, so it works with no stylesheet at all, and the cursor classes had already stopped being used — a dragged element takes its cursor from createDrag.

NumberInput formats through format and parse only

units and digit are removed. They were a second way to say the same thing, and format silently won whenever both were given.

// before
<NumberInput.Root units={[['Hz', 1], ['kHz', 1000]]} digit={2} ... >

// after
<NumberInput.Root {...unitFormat('Hz', { digits: 2 })} ... >

unitFormat comes from @tremolo-ui/functions and returns the format and parse pair together, so it spreads straight into the input. It picks an SI prefix by magnitude; pass prefixes: false for a unit that takes none, and base for a value stored in a prefixed unit.

// a single symbol appended as-is, which is what `units='dB'` used to do
<NumberInput.Root {...unitFormat('dB', { prefixes: false, digits: 1 })} ... >

// the value is in milliseconds: 1500 shows as 1.5s
<NumberInput.Root {...unitFormat('s', { base: 'm' })} ... >

Units, formatValue, parseValue and selectUnit are removed from @tremolo-ui/functions with them.

Text with no number in it now reads as NaN rather than 0, and the input keeps the value it had. Emptying the field and leaving it used to commit a 0. A hand-written parse should do the same for text it cannot read.

MIDI input takes a handlers object

useMIDIInput grew from three handlers to seven, which positional arguments no longer carry.

// before
useMIDIInput(midiAccess, onNoteOn, onNoteOff, onPitchBend)

// after
useMIDIInput(midiAccess, {
onNoteOnEvent: onNoteOn,
onNoteOffEvent: onNoteOff,
onPitchBendEvent: onPitchBend,
})

Every handler is now given the channel as its last argument, as 0–15. Existing handlers ignore it and keep working.

onPitchBendEvent receives one 14-bit value rather than two raw bytes, and the two bytes it used to be given were named the wrong way round — pitch bend sends the low 7 bits first.

// before: the arguments were labelled (msb, lsb), but arrived as (lsb, msb)
onPitchBendEvent: (msb, lsb) => setBend(((lsb << 7) | msb) - 8192)

// after
onPitchBendEvent: (value) => setBend(value - PITCH_BEND_CENTER)

useMIDIAccess().request takes options now, so passing it straight to an event handler sends the click event in as the options object.

// before
<button onClick={request}>Connect</button>

// after
<button onClick={() => request()}>Connect</button>

PointsEditor

grid is removed — it was never implemented, and leaked onto the DOM as a grid attribute. children is now required, matching the other components.

readonly and disabled on PointsEditor.Root used to be ignored by the points. They now take effect, so an editor that set readonly and relied on the points still moving has to drop it.

0.5.0

Piano is no longer built from key components

Piano.WhiteKey, Piano.BlackKey and Piano.KeyLabel are removed, along with the KeyProps / KeyMethods / KeyLabelProps types. Piano.Root draws the keys itself, and per-key customization is two callbacks.

// before
<Piano.Root noteRange={range} onPlayNote={play} onStopNote={stop}>
<Piano.WhiteKey />
<Piano.BlackKey />
</Piano.Root>

// after
<Piano.Root
noteRange={range}
onPlayNote={play}
onStopNote={stop}
keyProps={(note, { keyType }) => ({ 'data-in-scale': inScale(note, root, 'major') })}
/>

Each key carries data-note, data-note-key, data-active and aria-disabled, so static styling needs no callback at all — plain CSS on .tremolo-piano-white-key / .tremolo-piano-black-key reaches everything.

Other changes to Piano:

  • label takes (note, state) rather than (note, index); the index is state.index. A label of '', null or undefined draws nothing rather than an empty box
  • whiteNoteWidth is whiteKeyWidth. The dead blackNoteWidth is replaced by blackKeyWidthRatio / blackKeyHeightRatio / keyGap
  • getNoteRangeArray and NoteRange moved to @tremolo-ui/functions
  • KeyboardShortcuts.flags is removed. It was declared but never implemented; SHORTCUTS.HOME_ROW_NATURAL covers what naturalOnly was for

Slider.Scale is Slider.Marks

The name clashed with the value-curve Scale introduced in the same release.

// before
<Slider.Scale options={[...]} />
<Slider.ScaleOption value={50} />

// after
<Slider.Marks options={[...]} />
<Slider.MarksOption value={50} />

ScaleProps and ScaleOptionProps become MarksProps and MarksOptionProps.

skew is replaced by scale

A skew factor is now one of a set of named scales, which behave properly at the ends of the range.

// before
<Slider.Root skew={0.5} ... />

// after
<Slider.Root scale={skewScale(0.5)} ... />

linearScale is the default. exponentialScale suits frequency, curveScale(n) takes an exponent, symmetricSkewScale(n) bends both halves of a bipolar range, and skewScale(n) matches a JUCE parameter exactly.

NumberInput is redesigned

  • variant, activeColor, wrapperClassName, keepWithinRange and clampValueOnBlur are removed from Root. clampValue replaces the last two
  • selectWithFocus, blurOnEnter, onFocus and onBlur move to InputField; selectWithFocus is now selectOnFocus
  • The tab stop is the <input>, which carries role="spinbutton" and the aria-value* attributes. data-error is data-out-of-range
  • Stepper's dynamic, and size on IncrementStepper / DecrementStepper, are replaced by CSS variables

The wheel only acts while the focus is inside

Every component used to change value on a wheel event whether or not it had focus, so scrolling a page past one silently changed it. They now require the focus to be inside. Nothing to change in your code; the behaviour is different.

0.4.0

useDragWithElement is replaced by useDragValue

// before
const ref = useDragWithElement(({ x, y }) => setValue(x * 100))

// after
const { refCallback, dragging } = useDragValue({
axis: { min: 0, max: 100 },
baseElementRef: trackRef,
onChange: ([x]) => setValue(x),
})

useDragValue reports values rather than normalized coordinates, and covers both mappings: baseElementRef normalizes the pointer against an element, and getValue moves the value away from where it stood by the distance dragged.

useDrag returns a ref callback

// before
const [ref] = useDrag(handlers)

// after
const ref = useDrag(handlers)

DragObserver and WheelObserver are removed

Use useDrag and useWheel, or createDrag and createWheel from @tremolo-ui/dom outside React.

XYPad's XYOrSingle is XYInput

Its pair form is a readonly tuple. Where the value could itself be an array — scale, for one — only the pair form is accepted, since a lone array would be read as a pair.

Still to come

The CSS is not headless yet: @tremolo-ui/react ships index.css files and you are expected to import them. That will change before 1.0, and this page will carry the steps.