Changelog: @tremolo-ui/react
0.9.0
Minor Changes
-
#403
1001907Thanks @m1m0zzz! - Report when a change of value starts and ends, whatever makes it, for hosts that record automation and need to know when a control is touched.Knob,SliderandXYPadtakeonChangeStart(value, source)andonChangeEnd(value, source)(change-start/change-endin Vue). The start gets the value before the change, the end the value it ended on, andsourcesays what made it:'pointer','wheel','keyboard'or'doubleClick'.- A drag starts on the press and ends on release
- The wheel and the arrow keys have no end of their own, so the change ends
changeEndDelay(default 500 ms) after the last notch or key press - A double click on
Knobthat resets the value starts and ends around it
These replace
onDragStart/onDragEndonSliderandXYPad(drag-start/drag-endin Vue): a drag is now one of the sources. To keep reacting to drags only, checksource === 'pointer'.The tracking lives in
@tremolo-ui/domascreateChangeGesture, with theChangeSourcetype.createDragValuenow callsonDragStartbefore theonChangeof the press withupdateOnPointerDown, so that a drag is reported as started before its first value. -
#398
5988052Thanks @m1m0zzz! - Name the props that take a colour*Color, aftercoloronSlider.ThumbandXYPad.Thumb.Knob.Thumbdrops thethumbprefix from all its props, which only repeated the part's name:thumb→color,thumbLine→lineColorthumbSize→size,thumbLineWeight→lineWeight,thumbLineLength→lineLengthclasses.thumbLine→classes.line
Slider.TracktakesactiveColor/inactiveColorinstead ofactive/inactive, and the custom properties they write are renamed to match:--active→--active-color,--inactive→--inactive-color. A theme that reads or sets the old names on the track needs the new ones; the demo theme in the docs is updated.Knob.ActiveLine/InactiveLinekeepstrokeandstrokeWidth, which go straight onto the SVG attributes of the same names. -
#406
c9df5f4Thanks @m1m0zzz! - Split@tremolo-ui/dominto two entries before 1.0, when everything in the main one comes under semver.The main entry keeps what users of the components and of this package write: the
create*interactions, the values and types that appear in props (the default input options,SHORTCUTS, the mappings,XYand so on), and the geometry a part of your own needs to draw (KNOB_VIEWBOX_SIZE,knobArcPath,knobArcRadius,knobAngles,valuePercent,notePosition).What only the wrappers use moves to
@tremolo-ui/dom/internal, which is not covered by semver: the number input's text and draft handling (parseNumberText,numberSpan,commitNumberInputText,nudgeNumberInput, ...),createStepperDrag, the key and wheel direction helpers,applyDeltaandselectModifier, the piano layout apart fromnotePosition,clampPoint/POINT_AXIS/POINTS_EDITOR_DEFAULT_*,sliderMarks,partitionByAccept,replaceOptions,checkSteps,cssLength,visuallyHiddenStyleandtoXY.The wrappers now depend on
@tremolo-ui/domat the exact version rather than a caret range, since they use the internal entry. -
#397
885853fThanks @m1m0zzz! - ReplaceexternalStyles: { cursor }with adragCursorstring onKnob,Slider,XYPadandPointsEditor. The object only ever held the cursor shown while dragging. The defaults stay as they were:'grabbing'forKnoband the points ofPointsEditor,'pointer'forSliderandXYPad.externalStyles={{ cursor: 'move' }}→dragCursor="move"
NumberInputtakes adragCursortoo, for dragging aStepper, where the cursor was fixed to'ns-resize'.createStepperDragin@tremolo-ui/domtakes the matchingcursoroption.The
PointsEditorcontext carries it asdragCursor, in place ofexternalStylesin React andcursorin Svelte and Vue. -
#393
9c74838Thanks @m1m0zzz! - Remove theDrawFunctionandInitFunctiontypes from the wrappers. They were other names forCanvasDrawFunctionandCanvasInitFunctionin@tremolo-ui/dom, whichAnimationCanvasnow takes directly: import those instead.The Svelte and Vue
AnimationCanvasno longer say thatinitruns again after a resize. It never did: it runs once, before the first frame. -
#396
1bcd0b1Thanks @m1m0zzz! - ReplaceKnob'sdefaultValueandenableDoubleClickDefaultwith oneresetValue?: number | null. It is the value a double click restores, andnullturns the double click off, asnulldoes forwheelandkeyboard.default*reads as the starting value of an uncontrolled control, which this never was.The value restored by default is now
startValuerather thanmin, so a bipolar knob such as a pan returns to its centre without saying so twice.startValueitself still defaults tomin.defaultValue={v}→resetValue={v}enableDoubleClickDefault={false}→resetValue={null}
-
#404
92f4bf1Thanks @m1m0zzz! -NumberInputreports when a change of its value starts and ends, likeKnob,SliderandXYPad:onChangeStart(value, source)/onChangeEnd(value, source)(change-start/change-endin Vue), withchangeEndDelay.- A press on a stepper is one change from the press to the release, wherever the pointer ends up: the first step, the repeats while it is held, and a drag on the stepper all belong to it
- Typing starts a keyboard change with the first character that changes the value, and committing the text ends it
- The wheel and the arrow keys end
changeEndDelayafter the last notch or key press
-
#405
3126d10Thanks @m1m0zzz! -PointsEditor.Pointreports when a change of it starts and ends, like the other controls, in place ofonDragStart/onDragEnd(drag-start/drag-endin Vue):onChangeStart(value, source)/onChangeEnd(value, source)(change-start/change-end), withchangeEndDelay. A drag, the wheel while the point has the focus, and the arrow keys all count.The change belongs to the point being operated: the points that move along with a selection report through
onChangeonly, as they did before.PointsEditorPointin@tremolo-ui/domtakes an optionalbeforeWheel, whichnudgeFocusedPointcalls just before the wheel moves the point. -
#402
b6a0c5eThanks @m1m0zzz! -refnow reaches the element a part renders, on every part. The methods move to a newactionsRefprop.Knob.Root,Slider.Root,Slider.Thumb,XYPad.Root,XYPad.Thumb,NumberInput.RootandPiano.Rootused to giverefan object of methods (focus/blur, orplayNote/stopNote), with no way to the element itself; onlyXYPadpassed it along asoriginal, which is gone.ref={methodsRef}→actionsRef={methodsRef};reftakes the elementxyPadRef.current.original→refonXYPad.Root
focusandblurstay onactionsRefrather than leaving you to call the element's own: they do nothing while the control is disabled, and onSlider,XYPadandNumberInputthey reach the input inside that takes the focus, which the root element is not. -
#376
f1fd2b9Thanks @m1m0zzz! - RemoveuseInterval, and reworkuseEventListeneranduseAnimationFrame.useEventListenerno longer listens ondocumentwhen the target isnull:nulland a function returningnullboth listen on nothing, so passdocumentby name. A target and options written inline are compared by what they resolve to, and the listener is re-attached only when the element, the event or an option changes.signalis no longer accepted, and the argument types are exported asUseEventListenerTargetandUseEventListenerOptions.useAnimationFramerequires its callback and passes it the frame's timestamp and the milliseconds since the previous frame. Thedepsargument is replaced by{ disabled }, which stops the loop; the callback was already read on every frame, sodepsonly restarted it. The options are exported asUseAnimationFrameOptions. -
#400
68501ceThanks @m1m0zzz! - RenamereadonlytoreadOnlyonKnob,NumberInput,PointsEditor,PointsEditor.Point,SliderandXYPad, after React's own DOM props. The contexts that carry it (NumberInput,PointsEditor,Slider,XYPad) usereadOnlytoo. Thedata-readonlyattribute does not change, and Svelte and Vue keepreadonly.readonly→readOnly
-
#399
e076f9dThanks @m1m0zzz! - ReplaceSlider'sverticalboolean withorientation: 'horizontal' | 'vertical', the same values the parts already carry asdata-orientation(as in Radix and Base UI).vertical→orientation="vertical"
The slider context exposes
orientationin place ofverticaltoo, for parts of your own that read it.
Patch Changes
- Updated dependencies [
1001907,ecabb23,c9df5f4,885853f,62a07a4,3126d10,d623e55]:- @tremolo-ui/dom@0.9.0
- @tremolo-ui/functions@0.9.0
0.8.0
Minor Changes
-
#334
d4417e3Thanks @m1m0zzz! - Stop re-exporting what belongs to@tremolo-ui/dom. Import these from@tremolo-ui/dominstead:DEFAULT_KEYBOARD_OPTIONS,DEFAULT_WHEEL_OPTIONS,DEFAULT_DRAG_SENSITIVITY,MarksOptions,XY,XYInput,NOT_SUPPORTED,PERMISSION_DENIED,UNAVAILABLE,MIDIAccessError,MIDIAccessOptions,PITCH_BEND_CENTERandMIDIInputHandlers. -
#360
221b3efThanks @m1m0zzz! - Treat everything in a number input's text that is not the unit as the number.selectOnFocus="number"andkeepCaretOnStepnow cover a signed number (+6.0 dB,−6.0 dB), an exponent (1e+21) and a number after a unit (L 30), where they used to select nothing or stop part way.The default
parsereads the same number, and givesNaN, which leaves the value alone, for one it cannot read whole instead of the digits in front:1,000 Hzand1:30no longer commit 1, and a number with a unit in front is left to aparseof your own. -
#337
6a1710fThanks @m1m0zzz! -createPianoInputnow plays notes from the computer keyboard itself, with the newkeyboardShortcutsandkeyboardShortcutsScopeoptions, so that every wrapper's Piano handles held keys, focus loss and changes to the mapping the same way.SHORTCUTSandKeyboardShortcutsmoved to@tremolo-ui/dom: import them from there instead of@tremolo-ui/react. -
#339
b7dec12Thanks @m1m0zzz! - AddcreatePointsEditor, the selection and the moves behindPointsEditor: which points a press or a selection box selects, and how a selection moves as one and stops together at the edge.clampPointand the point type moved to@tremolo-ui/dom; importclampPointandPointPosition(formerlyPointBaseType) from there.PointsEditorContextValuenow carries the editor instance instead of its individual functions. -
#318
e7e695bThanks @m1m0zzz! - Remove appearance props that only forwarded a value to one CSS declaration:Piano.Root.height,PointsEditor.Root.widthandheight,PointsEditor.Point.size,width, andheight,Slider.MarksOption.labelWidth, andXYPad.Area.width,height, andcolor. Set those values through CSS orstyleinstead. The demo theme also uses ordinary CSS declarations for Piano key colours.XYPadAreaPropsis no longer exported; useComponentProps<typeof XYPad.Area>.
Patch Changes
-
#361
e04290cThanks @m1m0zzz! -NumberInput.InputFieldno longer drops what is typed when it is given anonChangeof its own. The handler runs after the field has taken the text. -
#362
eaeeb6fThanks @m1m0zzz! -FileInputandDropZonereport the rejected files after the accepted ones for the same selection or drop. A list of rejected files cleared inonChange/onDropand filled inonRejectnow shows what a mixed selection left out, where it used to be cleared straight away. -
Updated dependencies [
4a7b743,63d750f,a3bb088,8309125,221b3ef,6a1710f,b7dec12,eaeeb6f,479f29e]:- @tremolo-ui/dom@0.8.0
- @tremolo-ui/functions@0.8.0
0.7.0
Minor Changes
-
#305
63ea333Thanks @m1m0zzz! -DropZoneis new. It takes files dropped onto an area and hands them over asFiles, stopping whereFileInputdoes.<DropZone.Root accept="audio/*" onDrop={(files) => load(files[0])}>Drop an audio file here</DropZone.Root>[data-dragover]while files are over it, and[data-invalid]when it can already tell it will not take them — the browser reports the type of what is being dragged, soaudio/*decides before the drop while.wavcannot- Leaving a child does not end the drag.
dragenteranddragleavefire for descendants too, so the pairs are counted - The drop is cancelled even when it is refused. An unhandled drop makes the browser leave the page and open the file
- A drag that ends elsewhere — dropped on another element, cancelled with
Escape, taken out of the window — sends no
dragleave, and is cleared anyway
useDropZoneis new, and is what the component is built on. It makes any element a drop target without wrapping it:const { refCallback, over, invalid } = useDropZone({ accept: 'audio/*', onDrop })return <canvas ref={refCallback} />createDropZoneis new in@tremolo-ui/dom, where the reading of the drag lives so that a wrapper for another framework uses the same one.The demo theme has a
DropZone.module.cssto go with it. -
#304
490ed19Thanks @m1m0zzz! -FileInputis new. It picks files and hands them over asFiles; reading them is yours.<FileInput.Root accept="audio/*" multiple onChange={(files) => load(files)}><FileInput.Trigger>Choose audio</FileInput.Trigger></FileInput.Root>acceptis checked again on the way in. The browser treats the attribute as a hint to the picker, which a person can switch to "All Files". What does not match arrives atonRejectrather thanonChange- Picking the same file twice works. A file input fires no
changeevent while the selection is unchanged, so the value is cleared as soon as it has been read - The native
<input type="file">is still the control: out of sight, in the tab order, and named byFileInput.Trigger, which is a<label>for it
matchesAcceptis new in@tremolo-ui/dom. It answers whether a file satisfies anacceptattribute, with the same extension, MIME type and type group syntax the attribute has.The demo theme has a
FileInput.module.cssto go with it. -
#306
75537d1Thanks @m1m0zzz! - The props types list only what a part adds.className,styleandchildrenare no longer declared on these types, because they had the same types the element attributes already give them:NumberInputProps,PointsEditorPointPropsSliderProps,SliderTrackProps,SliderThumbProps,SliderMarksPropsXYPadAreaProps,XYPadThumbProps
A part that adds nothing has no props type any more. These are removed:
NumberInputInputFieldProps,NumberInputStepperProps,NumberInputIncrementStepperProps,NumberInputDecrementStepperPropsPointsEditorBackgroundProps,PointsEditorContainerProps,PointsEditorSelectionBoxProps
Every part still takes the same attributes, and they still go where they went. If you typed props with one of the names, take the props of the component instead:
- const props: SliderThumbProps = { className: 'thumb' }+ const props: ComponentProps<typeof Slider.Thumb> = { className: 'thumb' }- const props: NumberInputStepperProps = { className: 'stepper' }+ const props: ComponentProps<typeof NumberInput.Stepper> = { className: 'stepper' } -
#306
75537d1Thanks @m1m0zzz! -SliderThumbPropsno longer listsaria-label,aria-labelledby,aria-describedbyandaria-valuetext.Slider.Thumbstill takes all four and still puts them on the range input inside it; only the named type changed.They were declared with the same types the
divattributes already give them, so they added nothing to whatSlider.Thumbaccepts. If you built props with the type directly, add the attributes back yourself:const props: SliderThumbProps & Pick<AriaAttributes, 'aria-label'> = {'aria-label': 'Level',} -
#307
7ec3ffaThanks @m1m0zzz! - Every part'sstyletakes CSS custom properties, and suggests its own. React'sCSSPropertieshas no key starting with--, so TypeScript refusedstyle={{ '--thickness': '16px' }}everywhere but onPiano. It is accepted on every part now, and the properties a part writes or reads come up in your editor:<Slider.Track style={{ '--thickness': '16px' }} />// ^ --active --inactive --length --percent --thicknessAny other
--name is still accepted, for the variables a theme reads.CSSVariablestakes those names as a parameter, without the leading dashes —CSSVariables<'gap'>gives'--gap'— and is exported from the package root rather than fromPiano. Its default is the same as before, soCSSVariableson its own still means any custom property. -
#303
106d86cThanks @m1m0zzz! -UseDragOptionsis exported. The options ofuseDraghad no name you could import, unlikeuseWheelanduseDragValue.import { useDrag, type UseDragOptions } from '@tremolo-ui/react'UseWheelOptionsno longer hasonWheel. It inherited the option fromWheelOptions, where it exists so thatcreateWheelcan replace the callback throughupdate().useWheeltakes the handler as its first argument, so anything passed in the options was silently ignored.The options of all three hooks are documented on their own pages now, with the descriptions coming from the JSDoc.
Patch Changes
-
#299
a2301eeThanks @m1m0zzz! -@tremolo-ui/functionsdrops five exports that were never meant for you. The package is for general-purpose functions — the ones worth having whether or not you use the rest of this library — and these were implementation details that became public by being pure.Removed Instead mod((n % m) + m) % m, three characters longer than the importxora !== b, or!!a !== !!bwhen either side may beundefinedintegerPartString(Math.trunc(x))decimalPartString(x).split('.')[1], though see belowSIGNIFICANT_DIGITSnothing. It was only ever the default of toPrecision, which still applies on its ownintegerPartanddecimalPartreturnedstring | undefinedand both broke on the exponent form:String(1e-7)is'1e-7', which has no decimal point, sodecimalPartreported no digits at all.Slider.Markslabels an interval written in exponent form correctly. It useddecimalPartto decide how far to round each label back, so asteporperof1e-7rounded every label to a whole number. It now counts the digits the exponent stands for. -
#302
67be12fThanks @m1m0zzz! - Reading the modifier keys of an input event moves to@tremolo-ui/dom.applyDelta,selectModifier,mapModifierand the typesInputEventOption,Modifier,ModifierMap,ModifierStateandModifierValueare no longer exported by@tremolo-ui/functions; import them from@tremolo-ui/dominstead.- import { applyDelta, type ModifierValue } from '@tremolo-ui/functions'+ import { applyDelta, type ModifierValue } from '@tremolo-ui/dom'ModifierStateis the shape of the modifier flags on a DOM event, andapplyDeltadecides how far one wheel notch or one key press moves a value — the same jobcreateDragValuealready does for a drag. They now sit together, which is also what a Vue or Svelte wrapper needs in order to implement keyboard control without reading the React components.selectInputEventis gone. It returned whatselectModifierreturns, withvaluerenamed tooption, and nothing else:- const { option, modifier } = selectInputEvent(keyboard, event)+ const { value: option, modifier } = selectModifier(keyboard, event)With this,
@tremolo-ui/functionsis four groups of general-purpose functions: value distributions, numeric conversion, music theory and display formatting. -
#301
6fbc117Thanks @m1m0zzz! - The geometry of a drawn keyboard moves to@tremolo-ui/dom.noteAt,notePosition,pianoWidth,blackKeyWidth,getNoteRangeArray,PianoLayoutandNoteRangeare no longer exported by@tremolo-ui/functions; import them from@tremolo-ui/dominstead. Nothing about them changed.- import { noteAt, type PianoLayout } from '@tremolo-ui/functions'+ import { noteAt, type PianoLayout } from '@tremolo-ui/dom'They are pixel positions and hit testing for a keyboard that has been drawn, which is the layer
@tremolo-ui/domis for.@tremolo-ui/functionskeeps the music theory —noteNumber,noteName,noteToFrequency, the scales — which is useful whether or not anything is on screen. -
Updated dependencies [
63ea333,490ed19,a2301ee,67be12f,6fbc117]:- @tremolo-ui/dom@0.7.0
- @tremolo-ui/functions@0.7.0
0.6.0
Minor Changes
-
#294
b9aac34Thanks @m1m0zzz! - State that is on or off is now written as an empty attribute:data-disabled=""rather thandata-disabled="true". The attributes affected aredata-disabled,data-readonly,data-dragging,data-selected,data-out-of-range,data-flipped,data-resizableand a key'sdata-active. Attributes that carry a value, such asdata-orientationanddata-note, are unchanged.Selecting on the attribute alone —
[data-disabled], which is what the docs and the demo theme have always used — keeps working. A selector that matches the value,[data-disabled="true"], no longer matches. -
#240
32c6e1bThanks @m1m0zzz! - Replace thetypeprop ofSlider.MarksOptionwithmarkandlabel.mark={false}leaves the mark out,label={null}leaves the label out, and any other label — including an empty string — is drawn as given instead of falling back to the value.Slider.Markstakes the interval directly (options="step",options={25}), withoptions={{ per, mark, label }}for a set without marks or without labels. -
#266
083ab5eThanks @m1m0zzz! - Take the accessible name through the standardaria-*props.XYPad.Thumbacceptsaria-label,aria-labelledby,aria-describedbyandaria-valuetext— one value for both axes, or a pair for one each — in place ofariaLabelsandariaValueTextonXYPad.Root, andPointsEditor.Pointtakesaria-labelandaria-valuetextthe same way. They reach the range inputs that carry the semantics; passing them before only decorated the wrapper. -
#209
f39293cThanks @m1m0zzz! - Keep MIDI access connected through React Strict Mode effect replays, and stop long-press repetition when the pointer is cancelled or the window loses focus. -
#210
4fff346Thanks @m1m0zzz! - Makedisabledprevent value changes and remove interactive controls from the tab order across Knob, Slider, XYPad, NumberInput, and PointsEditor. Prevent a read-only Knob from restoring its default value on double-click. -
#222
c8c10f3Thanks @m1m0zzz! - Render Slider semantics on a visually hidden native range input inside the thumb so focus and accessible value information stay on the same element. -
#289
fa26b76Thanks @m1m0zzz! -AnimationCanvasforwards a ref to its<canvas>element.const ref = useRef<HTMLCanvasElement>(null)<AnimationCanvas ref={ref} draw={draw} /> -
#150
9c5abb1Thanks @m1m0zzz! - Build out the MIDI input: every channel voice message, the channel they arrived on, and devices that come and go.Three bugs came out of it:
- A device plugged in after permission was granted never worked.
createMIDIMessagetook the input list once, when it was created, so a keyboard connected later got no listener and stayed silent until the component remounted. The device list is now followed throughstatechange. - Pitch bend reported its two bytes the wrong way round.
onPitchBendEvent(msb, lsb)was handed(data[1], data[2]), but pitch bend sends the low 7 bits first — the opposite order from every other message. useMIDIInputanduseMIDIMessageresubscribed on every render. The handlers were effect dependencies, so writing one inline tore the listeners down and built them again each time. They are read fresh on every event now, and the listeners stay put.
Beyond note on/off and pitch bend,
createMIDIInputnow decodes control change, program change, polyphonic aftertouch and channel pressure. Every handler is given the channel last, as 0-15.useMIDIInput(midiAccess, {onNoteOnEvent: (note, velocity, channel) => play(note, velocity / 127),onNoteOffEvent: (note) => stop(note),onControlChangeEvent: (controller, value) => {if (controller === 1) setModulation(value / 127)},})createMIDIAccessgained the rest of what a device UI needs:inputsin its state, kept current as devices come and go;request({ sysex: true })for system exclusive; and errors told apart rather than flattened —SecurityErrorandNotAllowedErrorbecomePERMISSION_DENIED,NotSupportedErrorbecomesNOT_SUPPORTED, and everything else becomes the newUNAVAILABLE. A user who said no can be asked again; a browser without the API cannot.Breaking changes:
useMIDIInput(access, onNoteOn, onNoteOff, onPitchBend)takes a handlers object instead:useMIDIInput(access, { onNoteOnEvent, onNoteOffEvent, ... }). Seven handlers do not fit in positional argumentsonPitchBendEventis(value, channel), wherevalueis the 14-bit bend 0-16383, centred at the newPITCH_BEND_CENTER(8192), rather than the two raw bytesuseMIDIAccess().requesttakes options, soonClick={request}has to becomeonClick={() => request()}— otherwise the click event arrives as the options object
- A device plugged in after permission was granted never worked.
-
#208
ba7be4bThanks @m1m0zzz! - Breaking: Piano keyboard shortcuts now listen from the focused root instead of the whole page by default. The root is a focusablegroup; setkeyboardShortcutsScope="window"to keep page-wide shortcuts. Held shortcut notes are released when mappings change, focus is lost, or the component unmounts, and shortcuts no longer play outside the displayed range or while editing text.Lowering
midiMaxnow releases active notes above the new limit, including every source and pointer holding them. -
#214
fc5b383Thanks @m1m0zzz! - 数値・音名・Piano geometry の境界条件を検証し、不正値や極端な有限値にも一貫した結果を返すよう修正します。InputEventOptions型エイリアスを削除し、ModifierValue<InputEventOption>に統一します。 -
#224
398b47eThanks @m1m0zzz! - Restore React 18 ref support for NumberInput, PointsEditor, Slider, and XYPad subcomponents by wrapping their exported definitions in forwardRef. -
#259
afce3a3Thanks @m1m0zzz! - Move the selection box ofPointsEditorinto the core ascreateSelectionBox: which items a rectangle covers, and whether a press adds to the selection or replaces it, no longer live in React.PointsEditorContextValue.selectionBoxnow carries the core'sSelectionBoxRect, and the React-onlySelectionBoxtype is gone. -
#186
cedd509Thanks @m1m0zzz! - Every appearance prop now writes a custom property, and the default moved into the CSS.<Slider.Track thickness={16} />// is exactly<Slider.Track style={{ '--thickness': '16px' }} />Four different shapes had grown up for the same idea: a prop that fell through to the CSS (
Knob'ssize), a prop with a default in JavaScript that a stylesheet could not override without!important(Slider.Track'slengthandthickness), a gradient built inline that CSS could not reach at all, and a prop that set a custom property (Slider.Thumb'scolor). The last one won.The prop, a stylesheet rule and an inline style are now three ways to say one thing, and the usual cascade decides. Because the default lives in the CSS, one rule changes every instance:
.tremolo-slider-track {--thickness: 16px;}A number is taken as pixels; a string is written through, so
'3rem','100%'and'auto'all work.Slider.Trackno longer paints itself, anddefaultStyleis gone. The track publishes--percent— where the value sits, the one number CSS cannot work out on its own — and the fill is a rule in the theme. Drawing the track yourself means writing that rule rather than opting out of ours.Positions computed from the value stay inline: a thumb's
left, a point'stop, theleftof a piano key. Those are the value, not a style.New state attributes:
data-flippedonSlider.Trackfor a value that grows from the far end,data-resizableonPiano, anddata-verticalonSlider.Marks.If you are not using the theme from the docs,
Slider.Track,XYPad.Area,PointsEditor,PointsEditor.PointandPianono longer carry a size of their own — give them one, in CSS or through the props that now feed it. -
#267
1c36b92Thanks @m1m0zzz! - Place the parts from the component rather than from the stylesheet. A thumb, a mark, a point and the selection box take themselves out of flow and centre on the value they are given, and the layers ofPointsEditorand the keys ofPianokeep their order whichever way they are written — so a stylesheet of your own cannot put a part in the wrong place by leaving a rule out. Where the placement has room for taste,--translatemoves a thumb, a mark or a point off its centre. -
#171
e3aa356Thanks @m1m0zzz! - Shift is now the fine-adjustment key on the arrow keys. Every component moves by a tenth of a step with it held, which is the convention on hardware controllers and in every DAW.wheelandkeyboardaccept a map naming the modifier if you want something else, and a plain tuple opts out of modifiers entirely:keyboard={{ default: ['raw', 1], alt: ['raw', 0.5] }}keyboard={['raw', 1]}Nothing is bound on the wheel: browsers turn shift+wheel into horizontal scrolling, so shift is not ours to take there.
Fixes shift+wheel on
XYPadandPointsEditor, which could only ever raise the x value. The direction was read fromdeltaY, which browsers leave empty once they move the scroll todeltaX. A trackpad's own horizontal gesture now moves x too, with no modifier. -
#175
eec3e53Thanks @m1m0zzz! - Shift makes aKnobdrag count a tenth as much, matching what it already does on the arrow keys.dragSensitivityrebinds it, or takes a bare number to use no modifier.Pressing or releasing the key mid-drag does not disturb the value: the travel so far is kept and the new sensitivity applies from the next movement. Holding it before the pointer goes down applies it from the first pixel.
relativeMappingof@tremolo-ui/domtakes asensitivitycallback for this, read on every move.selectModifierof@tremolo-ui/functionsresolves any per-modifier setting, not just an input amount. -
#241
4187480Thanks @m1m0zzz! - Listen for the wheel once onPointsEditor.Containerinstead of once perPointsEditor.Point. The focused point still takes the notch from anywhere over the editor, but the number of native listeners no longer grows with the number of points.PointsEditorContextValuegainsnudgeFocusedPoint, and a point's registration now carries its element and its resolved wheel option. -
#257
f9f8619Thanks @m1m0zzz! - Rename the rubber band ofPointsEditorto the selection box. The element is now.tremolo-points-editor-selection-box, andPointsEditorContextValuecarriesselectionBox,beginSelectionBox,moveSelectionBoxandendSelectionBoxin place of themarqueenames. -
#165
81fc481Thanks @m1m0zzz! -@tremolo-ui/reactno longer ships any CSS. The./styles/*.cssexport paths are gone,dist/index.cssis no longer built, and importing either one now fails to resolve.Components still render the same
tremolo-class names and the samearia-*/data-*state attributes, so the appearance is a stylesheet away — it just has to be yours. The theme the documentation uses is published at Styling as six plain CSS files to copy from.- import '@tremolo-ui/react/styles/index.css'+ import './tremolo-theme.css' // copied from the Styling page -
#183
a712bc2Thanks @m1m0zzz! - Shift makes aNumberInput.Stepperdrag fine too.dragSensitivityonNumberInput.Rootsays how much the drag counts per modifier key, default{ default: 1, shift: 0.1 }:dragpixels then move a tenth of asteprather than a whole one. As on the arrow keys, a modifier amount is not snapped back onto the grid.The three handlers of
useDragnow receive the wholeDragState.onDraghad only the four numbers, and the modifier keys live on the event:useDrag({ onDrag: (x, y, deltaX, deltaY, state) => state.event.shiftKey })Arguments were added rather than replaced, so existing handlers keep working.
mapModifieris new in@tremolo-ui/functions. It carries a per-modifier setting over to another kind of setting, keeping which modifier each belongs to:mapModifier({ default: 1, shift: 0.1 }, (f) => ['raw', step * f])// { default: ['raw', 1], shift: ['raw', 0.1] }Keeping the map rather than resolving it first is what lets a fine drag move at all — naming a modifier is also what takes
stepout of the pipeline. -
#157
2ff5c1dThanks @m1m0zzz! -NumberInputno longer takesunitsordigit.formatandparseare the only way to control the displayed text, andunitFormatbuilds both:- <NumberInput.Root units={[['Hz', 1], ['kHz', 1000]]} digit={2} ... >+ <NumberInput.Root {...unitFormat('Hz', { digits: 2 })} ... >Units,formatValue,parseValueandselectUnitare removed from@tremolo-ui/functionsalong with them.parsenow reads text with no number in it asNaN, and the input keeps its current value instead of committing a zero the user never typed. -
#187
0721aa2Thanks @m1m0zzz! -PointsEditorselects points, and a selection moves as one, withselectable.It is off by default. Selection 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.
- 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
- dragging or arrow-keying one selected point moves the whole selection
The editor keeps the selection unless you take it over with
selection/onSelectionChange. Points are named by theiridprop, or by one generated to last as long as the point is mounted. A selected point carriesdata-selected="true", and the rubber band is.tremolo-points-editor-marquee.A selection stops as a whole when any one of its points reaches a limit. Clamping each point on its own would leave that one behind while the rest carried on, pulling the selection out of shape.
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 also what makes moving several at once mean anything.
Deleting and duplicating are not included: the points are yours, and only you know what the array behind them is.
createDragof@tremolo-ui/domtakesshouldStart, which decides whether a pointerdown starts a drag at all. It is checked before the pointer is captured, which is the whole point: a rubber band on a container has to decline a press that landed on one of the objects it would select, and declining any later means the capture has already been taken away from the object that was going to handle it.useDragValue's handlers receive theDragStateas a second argument, the wayuseDrag's do.Two things to know when you turn it on.
Update each point from the previous state. A selection calls
onChangeon 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:// goodonChange={(v) => setPoints((prev) => ({ ...prev, [id]: v }))}// throws away every call but the lastonChange={(v) => setPoints({ ...points, [id]: v })}The rubber band puts a drag on
PointsEditor.Container, and a drag setstouch-action: noneon what it holds, so dragging a finger across the editor draws a selection rather than scrolling the page. That was already true over a point; withselectableit is true over the whole surface. Without it the container takes no drag at all. -
#177
c01b036Thanks @m1m0zzz! -NumberInput.RoottakeskeepCaretOnStep, which puts the caret inInputFieldback where it was after an arrow key steps the value. Without it a controlled input drops the caret at the end, so holding the key down always acts on the last digit and a column cannot be held.The position is measured from the decimal point, so it survives the number changing length: the caret between
9and.9is still between10and.0.Arrow keys no longer step the value while an IME is converting, where they belong to the candidate list.
-
#169
3e307dfThanks @m1m0zzz! -NumberInput.RoottakesunformatOnFocus. With it set,InputFielddrops whateverformatput around the value while it has focus and shows the plain number, so a field reading1.23kHzoffers1230to type over.The number shown is the value itself rather than the number inside the formatted text —
1.23would read back as 1.23 and lose a factor of a thousand. That also stops a rounded display becoming the value: a field withdigits: 0showing2Hzfor 1.6 now offers1.6for editing.Off by default.
-
#263
593c112Thanks @m1m0zzz! - Name the orientation onSliderinstead of asking whether it is vertical: the parts now carrydata-orientation="horizontal"ordata-orientation="vertical"in place ofdata-vertical, which is what the rest of the ecosystem uses. Thedata-axisonXYPad's inputs isx/yas well, matchingPointsEditor. -
#204
5e4aed7Thanks @m1m0zzz! -Slider.ThumbandXYPad.Thumbare one element, andchildrenare rendered inside it.The thumb used to be a positioned wrapper around either the caller's children or a default thumb of its own. The wrapper could not be given a
classNameor astyle—XYPad.ThumbtookwrapperClassNameandwrapperStylefor it andSlider.Thumbhad no way at all — and passing children silently droppedclassNameandstyle, along with the ref thatRoot.focus()and the focus-on-drag both go through.Now there is a single element. It carries the position, the class name, the state attributes and the focus, and whatever is passed as
childrenis drawn inside it.-<Slider.Thumb>- <img src="thumb.png" />-</Slider.Thumb>+<Slider.Thumb style={{ background: 'none', width: 'auto', height: 'auto' }}>+ <img src="thumb.png" />+</Slider.Thumb>Breaking.
- The
tremolo-slider-thumb-wrapperandtremolo-xy-pad-thumb-wrapperelements are gone. CSS written against them belongs ontremolo-slider-thumb/tremolo-xy-pad-thumb, which now needs the positioning the wrapper used to hold:position: absoluteand atranslate: -50% -50%to centre it on its point XYPad.Thumbno longer takeswrapperClassNameorwrapperStyle. ItsclassNameandstylereach the same element those used to- Children no longer replace the thumb. Where they were a whole replacement,
turn the thumb's own appearance off through
styleorclassName - A thumb was hidden by passing an empty fragment as its children, which the
truthiness check read as "the caller drew something". Use
style={{ display: 'none' }} childrenof0or''now render, instead of being replaced by the default thumb
Slider.Thumbalso accepts the props of thedivit renders, asXYPad.Thumbalready did. - The
-
#211
0e3e40fThanks @m1m0zzz! - ExportcomposeRefsanduseComposedRefsfrom the@tremolo-ui/react/compose-refssubpath. -
#251
e318684Thanks @m1m0zzz! - Draw the children ofPointsEditor.Pointinside the point. They were dropped, so a point whose look came from its children rendered as nothing. -
#289
fa26b76Thanks @m1m0zzz! - Every exported props type now starts with the name of its component, so the list of types in@tremolo-ui/reactsays which component each one belongs to.Before After StepperPropsNumberInputStepperPropsIncrementStepperPropsNumberInputIncrementStepperPropsDecrementStepperPropsNumberInputDecrementStepperPropsNumberInputFieldPropsNumberInputInputFieldPropsPointPropsPointsEditorPointPropsMarksPropsSliderMarksPropsMarksOptionPropsSliderMarksOptionPropsKnobContextValue,NumberInputContextValue,SliderContextValueandXYPadContextValueare now exported, asPointsEditorContextValuealready was. Each is whatuse<Component>Contextreturns and what its selector receives. -
#182
30e927aThanks @m1m0zzz! - Shift now makes aSlider,XYPadorPointsEditordrag fine, the way it already did onKnob. All four takedragSensitivity, default{ default: 1, shift: 0.1 }, and a bare number opts out of modifiers.These three are different from
Knob: their value is the position pointed at, so a fine drag cannot stay under the pointer. It moves a tenth as fast and the two drift apart — and they stay apart once the key is released. Snapping the value back under the pointer would move it by however far they had drifted, which is a jump nobody asked for.Pressing or releasing the key partway through does not disturb the value, and a plain click still lands where it was aimed.
elementMappingof@tremolo-ui/domtakes the samesensitivitycallbackrelativeMappingdoes:elementMapping(() => track, {sensitivity: (state) => (state.event.shiftKey ? 0.1 : 1),})It reports
origin + (position - anchor) * sensitivity, where origin and anchor only move when the sensitivity does. With no callback the two never part, so the result is the raw position and nothing changes. -
#260
32a4abfThanks @m1m0zzz! - AddPointsEditor.SelectionBox, the box a drag on empty space draws. It takesclassName,style,childrenand the rest of a div like every other part, and the container no longer draws one of its own — place it insidePointsEditor.Containerto keep the box. -
#284
6302116Thanks @m1m0zzz! -NumberInput.InputFieldno longer takesselectOnFocusorblurOnEnter. Set them onNumberInput.Root, which already holdsdrag,dragSensitivityandpointerLockfor theStepper. The parts keep only what belongs to their own element:className,style,refand the rest of its attributes.- <NumberInput.Root value={value} onChange={setValue}>- <NumberInput.InputField selectOnFocus="number" blurOnEnter={false} />+ <NumberInput.Root value={value} onChange={setValue} selectOnFocus="number" blurOnEnter={false}>+ <NumberInput.InputField /></NumberInput.Root> -
#242
5f8cb13Thanks @m1m0zzz! - Keep theuseAnimationFrameloop running across renders when the callback is written inline. The callback is now read through a ref, so only thedepsgiven to the hook restart the loop — an inline callback that renders no longer cancels and re-schedules its own loop on every frame. -
#288
36a1c39Thanks @m1m0zzz! -AnimationCanvasandPianonow call following the size of the parent elementresizable.relativeSizeonAnimationCanvasandfillonPianoare gone.- <AnimationCanvas relativeSize draw={draw} />+ <AnimationCanvas resizable draw={draw} />- <Piano.Root fill>+ <Piano.Root resizable>The props of
AnimationCanvasare split byresizable.widthandheightbelong to a fixed canvas, and passing them together withresizableis now a type error; before, both were accepted andwidth/heightwere silently ignored. The types are renamed to match:Before After CommonPropsAnimationCanvasCommonPropsAbsoluteSizingPropsAnimationCanvasFixedPropsRelativeSizingPropsAnimationCanvasResizablePropsreduceFlickeringmoves toAnimationCanvasCommonProps: a fixed canvas is resized too when itswidthorheightchanges, and the option applies there as well. -
#223
a89106eThanks @m1m0zzz! - Expose the x and y values of XYPad thumbs and PointsEditor points as separate visually hidden range inputs with configurable accessible names, and group the two XYPad axes. -
#271
b3cf7e4Thanks @m1m0zzz! - Stop shipping class names. A part carries what you pass toclassNameand nothing else — thetremolo-classes are gone, and with them the stylesheet that could reach in without being asked. Styling is now: give every part a class of your own, read the state from thedata-attributes, and copy the theme from the documentation as a starting point.Knob.Thumblosesclasses.thumb, which named the same element as itsclassName. -
#253
b18e938Thanks @m1m0zzz! - Keep the focus insideSliderandPointsEditor.Pointafter a press. Neither the track, the thumb nor the point could hold focus, so the browser cleared it to the body and undid the focus the drag had just given the hidden input — leaving the arrow keys dead until the control was tabbed to. Both now take the focus themselves and pass it on to the input. -
#252
5142435Thanks @m1m0zzz! - Move both axes ofXYPadandPointsEditor.Pointwith the arrow keys, whichever of the two range inputs holds the focus. The axis used to be read off the focused input, and since the focus lands on the x input, the up and down keys did nothing. -
#164
a6dbfeeThanks @m1m0zzz! -@tremolo-ui/react/styles/global.cssis removed, along with the.tremolo-user-select-noneand.tremolo-cursor-*classes it held.Nothing has to be done to keep the same behaviour: the page-wide
user-select: nonea drag applies is now an inline style set by the component, so it works without any stylesheet. The.tremolo-cursor-*classes had already stopped being used — a dragged element takes its cursor fromcreateDrag. -
#212
8fd96feThanks @m1m0zzz! - RemoveexternalStyles.userSelectNonefrom Knob, Slider, XYPad, and PointsEditor. The React body-style guard duplicated drag selection handling in@tremolo-ui/domand did not prevent iOS Safari's existing unfocused long-press selection.externalStyles.cursornow accepts every CSS cursor value.Export the shared keyboard, wheel, and drag defaults as
DEFAULT_KEYBOARD_OPTIONS,DEFAULT_WHEEL_OPTIONS, andDEFAULT_DRAG_SENSITIVITY. -
#239
4c71ba8Thanks @m1m0zzz! - ExportuseKnobContext,useXYPadContextand the SliderMarksOptions/MarksTypetypes from the package root so every component exposes its context hook and related types consistently. -
#264
d43b890Thanks @m1m0zzz! - Say a state by the attribute alone. Everydata-state is now written only while it is on —[data-disabled],[data-dragging],[data-selected]and the rest, rather than="true"and="false"— and the wrappers that are not controls of their own carrydata-disabled/data-readonlyin place of the ARIA they had. The controls themselves (Knob,NumberInput.InputField, the steppers, the range inputs inside a thumb) keep their ARIA as well: that says what they are, while thedata-attributes are what the styles read. -
#255
603d6afThanks @m1m0zzz! - Hand the focus to the selection a rubber band leaves behind inPointsEditor. The press that starts the band takes the focus out of the editor, so a selection made that way could not be moved with the arrow keys or the wheel until one of its points was clicked again. -
#185
5ebb50eThanks @m1m0zzz! -pointerLockhides the cursor for the length of a drag onKnobandNumberInput.Stepper, reading the pointer movement directly instead of following it around the screen.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 refusal is not an error: the drag carries on as an ordinary one, since the coordinates are only read as movement once the lock is actually held. Losing the lock ends the drag, because no pointerup is coming after it.
Movement that happened while the request was in flight is kept, so the value does not jump when the lock takes effect.
Not for
Slider,XYPadorPointsEditor: their value is the position pointed at, andclientX/clientYfreeze under the lock.createDragandcreateDragValueof@tremolo-ui/domtake the option, as do theuseDraganduseDragValuehooks.
Patch Changes
-
#227
880d9c6Thanks @m1m0zzz! - Draw 360-degree Knob active and inactive lines as multiple SVG arc segments so full-circle endpoints remain visible. -
#207
5f12a47Thanks @m1m0zzz! - End every active drag when its instance is destroyed or its pointer capture is lost. These paths use the existingonDragEndcallback, as cleanup is required after every drag that started regardless of how the pointer stopped tracking.React controls now balance the page-wide
user-select: nonethey acquire even when they unmount or become readonly during a drag. -
#294
b9aac34Thanks @m1m0zzz! - Export the props types ofKnob.SVGRootandKnob.ThumbasKnobSVGRootPropsandKnobThumbProps.NumberInputIncrementStepperPropsandNumberInputDecrementStepperPropsare now interfaces rather than aliases, so the API reference lists their props.Every props type now documents each of its props, with
@defaultwhere there is one. The descriptions ofwheelandkeyboardno longer say that shift moves a tenth of a step: the amount is in the units of the value, which is a tenth of a step only whilestepis 1. -
#220
b8276b4Thanks @m1m0zzz! - Keep finite NumberInput values beyond JavaScript's safe-integer range when clamping is disabled or the corresponding range endpoint is not specified. -
#178
702d0a9Thanks @m1m0zzz! - Importing one component now brings only that component. Tree shaking was not working at all: a build that used nothing butKnobstill carried Piano, Slider, XYPad, NumberInput and PointsEditor. Bundled with esbuild, that came to 32,362 bytes against 39,113 for importing the whole package.The cause was
forwardRef(...)andcreateContext(...)being plain module-level calls. A bundler has to assume a call might have side effects, soconst Knob = { Root: forwardRef(...) }survives even whenKnobis unused. Both are pure, and are now annotated as such.import before after Knob32,362 11,567 Slider— 12,518 NumberInput— 10,498 Piano— 7,866 everything 39,113 39,113 clsxis no longer a dependency. Every call in the package wasclsx('tremolo-x', className)— a constant name plus the caller's own — with no object, array or nesting anywhere, so it is a one-line function now.@tremolo-ui/reactdepends on nothing but@tremolo-ui/domand@tremolo-ui/functions.sideEffectsisfalserather than["*.css"], which pointed at files the package stopped shipping when the CSS was removed. -
#149
b664f35Thanks @m1m0zzz! - Warn in development when a subcomponent is rendered at the wrong level.Children are rendered exactly as they are composed, so
<Slider.Thumb />left as a sibling of<Slider.Track>still renders — it just resolves itsposition: absoluteagainst the page instead of the track, and neither the build nor the tests notice. React says nothing either, except for the SVG parts ofKnob, where it reports an unrecognized<path>tag without saying which component was misplaced.Each of these now says so, naming the wrong parent when there is one:
Slider.ThumboutsideSlider.Track,Slider.MarksOptionoutsideSlider.MarksXYPad.ThumboutsideXYPad.AreaKnob.ActiveLine/Knob.InactiveLine/Knob.ThumboutsideKnob.SVGRootPointsEditor.PointoutsidePointsEditor.Container
It is a warning rather than an error, since the component does render, and the check is dropped from a production build.
-
#180
6d80c25Thanks @m1m0zzz! - A development build now says when a key press or a wheel notch cannot do anything visible. Two settings that are each fine on their own can cancel out, and nothing fails when they do — the control just sits there.keyboard={['raw', 0.1]}withstep={1}rounds every press straight back to where it started- a
formatshowing two decimals of a value in seconds cannot show a press worth one millisecond
Slider,Knob,XYPad(per axis) andNumberInputpress every entry ofkeyboardandwheelat nine points along their range and report what came of it.formatis called rather than read, so an arbitrary function is no obstacle: what matters is whether its output changes, not how many digits it has.A display that merely rounds is left alone — that is a deliberate choice and usually the right one. Only a display too coarse to show a press anywhere in the range is reported. Production builds carry neither the check nor the messages.
-
#221
a93d98fThanks @m1m0zzz! - Applyreverseto wheel input on horizontal Sliders as well as vertical ones, keeping wheel movement consistent with the visual and keyboard directions. -
#179
e180798Thanks @m1m0zzz! - Values no longer collect binary float debris. Holding shift and pressing the arrow key twelve times from 5 used to reach 5.699999999999998, and the input showed exactly that. The fine-adjustment modifier deliberately takesstepout of the pipeline, andstepwas the only thing rounding the artefact back.A value is now rounded to the 15 significant digits a double actually carries, at the two places a value is produced:
applyDelta, which the wheel and the arrow keys go through, andcreateDragValue, which every drag goes through. Rounding happens before the clamp, sominandmaxstill have the last word.This is not rounding in the sense
stepis.stepputs a value on a grid you asked for; this removes digits that were never in the value — the result of a float calculation already carries error that size or larger, so nothing real is lost. There is no way to turn it off, and no reason to want one.toPrecision(x, significantDigits = 15)is exported from@tremolo-ui/functionsalongside the existingtoFixed, withSIGNIFICANT_DIGITSfor the default. -
#230
0718c20Thanks @m1m0zzz! - Preserve custom Knob thumb classes, keep resizable Piano widths finite for ranges without white keys, and apply current dimensions when AnimationCanvas switches sizing modes. -
#228
83612dfThanks @m1m0zzz! - Make the disposer returned by useEventListener stable and ensure it removes the listener from the target used during registration. -
Updated dependencies [
91f2486,5f12a47,9c5abb1,ba7be4b,fc5b383,afce3a3,eec3e53,a712bc2,d1dc65f,2ff5c1d,0721aa2,041483e,30e927a,e180798,12d6463,36a1c39,7ec27cc,e31065c,83fa74b,84cbcdb,5ebb50e]:- @tremolo-ui/dom@0.6.0
- @tremolo-ui/functions@0.6.0
0.5.0
Minor Changes
-
#139
954dc53Thanks @m1m0zzz! - Move the drag-to-value logic into@tremolo-ui/domascreateDragValue.Slider,Knob,XYPadandPointsEditoreach turned pointer movement into a value on their own. They now share one primitive: a mapping decides where the pointer sits on the 0-1 travel of each axis, and the axis options (min/max/step/skew/reverse) turn that into a value, in the same order everywhere — position,reverse,skew, rounding to the step, clamping to the range.Two mappings ship with it.
elementMappingnormalizes the pointer against the bounding rect of an element, so the value is the position pointed at (Slider,XYPad,PointsEditor).relativeMappingmoves the value away from where it stood when the drag started, by the distance dragged (Knob).Breaking:
useDragWithElementis replaced byuseDragValue, which reports values rather than normalized coordinates and covers both mappings.const { refCallback, dragging } = useDragValue({axis: { min: 0, max: 100, step: 1 },baseElementRef: trackRef,updateOnPointerDown: true,onChange: ([x]) => setValue(x),})Breaking: the
XYOrSingletype ofXYPadis nowXYInput, and its pair form is areadonlytuple. A single value is told from a pair withArray.isArray, so where the value could itself be an array the single form is dropped and only the pair is left.createDragandcreateDragValuealso gainedupdate(), so a wrapper can feed fresh settings in without tearing down the listeners. Changingminormaxwhile a drag is in progress no longer aborts it.Fixes a crash when the element a drag normalizes against has collapsed to zero width or height: the position now reads 0 instead of throwing a
RangeError. -
#143
0d33294Thanks @m1m0zzz! - RenameSlider.ScaletoSlider.MarksBreaking.
scaleis now the prop that says how a value is distributed across the travel, so the subcomponent that draws the tick marks needed a name of its own —<Slider.Root scale={…}><Slider.Scale/></Slider.Root>read as though the two were related.-<Slider.Scale>- <Slider.ScaleOption value={0} type="mark-number" />-</Slider.Scale>+<Slider.Marks>+ <Slider.MarksOption value={0} type="mark-number" />+</Slider.Marks>before after Slider.ScaleSlider.MarksSlider.ScaleOptionSlider.MarksOptionScalePropsMarksPropsScaleOptionPropsMarksOptionPropsScaleOptionsMarksOptionsScaleTypeMarksType.tremolo-slider-scale.tremolo-slider-marks.tremolo-slider-scale-option.tremolo-slider-marks-option.tremolo-slider-scale-option-mark.tremolo-slider-marks-option-mark.tremolo-slider-scale-option-label.tremolo-slider-marks-option-labelThe
optionsprop and its behaviour are unchanged, and@tremolo-ui/react/styles/Slider.cssstill exports the same file. -
#144
27209dcThanks @m1m0zzz! - Move the AnimationCanvas loop into@tremolo-ui/domascreateAnimationCanvas, and stop restarting it on every renderAnimationCanvaskept its whole setup — the 2D context, theResizeObserver, the device pixel ratio, therequestAnimationFrameloop — inside one effect whose dependencies includeddraw,initandoptions. Those are written inline at almost every call site, so they were new on every render and the effect tore everything down and built it again:initran repeatedly, andcountandelapsedTimewent back to zero. A canvas next to anything that sets state — a meter reading its level, say — never got past frame 0.The loop now lives in
@tremolo-ui/dom:const instance = createAnimationCanvas(canvas, { draw, animate, size })instance.update({ draw: nextDraw }) // swaps the handler, keeps the loop runninginstance.redraw()instance.destroy()The React component is a wrapper that creates the instance once and pushes fresh handlers in with
update(), the same shapeuseDragValuealready used.Fixed along the way:
widthandheighthad no effect once mounted. They were not effect dependencies, so changing them reset the canvas's backing store without re-applying the device pixel ratio transform, leaving the drawing at the wrong scale.- The first size of a
relativeSizecanvas came fromparent.clientWidth, later ones from the observer'scontentRect. Those differ by the parent's padding, so a padded parent drew at one size and then jumped. TheResizeObservernow reports every size, including the first. - Resizing blurred the canvas on a HiDPI screen. The snapshot that carries the drawing across a resize was scaled down by the device pixel ratio on the way out and back up on the way in. The two cancelled out, so it landed in the right place at the right size, but it had been through a downscale and an upscale. It is now copied at the canvas's own device resolution and drawn back at its old CSS size, which is a 1:1 copy of device pixels while the ratio holds — and a single correct rescale from full resolution when the ratio changes.
- The hidden
<canvas>used to carry the drawing across a resize is no longer rendered into the document; the core makes one off-document when it needs it.
With
animateoff,update()draws a frame: the loop is not running, so a resize and that call are the only things that can put a new drawing on the canvas. That is what keeps the documented "reactive canvas" —useStatewithanimate={false}— repainting when state changes.optionsis no longer an effect dependency. It is read once when the context is created, so writing it inline no longer rebuilds the canvas on every render.AnimationCanvaskeeps the same props.@tremolo-ui/domgainscreateAnimationCanvas,drawingStateandisDrawingState, with theAnimationFrame,AnimationCanvasOptionsandAnimationCanvasInstancetypes. -
#142
0646236Thanks @m1m0zzz! - Replaceskewwithscale, and add scales that do not break down at the ends of the rangeBreaking. The
skewprop ofSlider.Root,Knob.Root,XYPad.RootandNumberInput.Root, andValueRange.skewin@tremolo-ui/functions, are replaced byscale, which takes aScale.-<Knob.Root min={20} max={22000} skew={skewWithCenterValue(663, 20, 22000)} …>+<Knob.Root min={20} max={22000} scale={exponentialScale} …>@tremolo-ui/functionsgains theScaleinterface and five scales:linearScale— the default. Equal travel, equal change in valueexponentialScale— equal travel, equal ratio: every octave takes the same distance. For frequency, free running rates and delay times. Requiresminandmaxto be non-zero and of the same signcurveScale(curve)— an exponential bend that still passes throughminandmax, so it works on a range that starts at or crosses 0.curve > 0favours the lower end,curve < 0the upper end. Pair it withcurveWithCenterValue()symmetricSkewScale(skew)— the same bend mirrored about the middle, for a bipolar control that needs fine adjustment around its centreskewScale(skew)— the power law of JUCE'sNormalisableRange, for a value that has to agree with a JUCE or iPlug2 parameter.skewWithCenterValue()still applies to it
A
Scaletakesminandmaxas arguments rather than holding them, so it carries no state and can be a module level constant.normalizeValue()andrawValue()lose theirskewparameter and are now the linear mapping alone — every curve lives in aScale.applyDelta()takes the scale through itsValueRange.skewWithCenterValue(),ValueRangeandapplyDelta()keep their behaviour and move next to the scales they belong to; the names exported from the package are unchanged.-normalizeValue(value, min, max, skew)+skewScale(skew).normalize(value, min, max)-rawValue(position, min, max, skew)+skewScale(skew).denormalize(position, min, max)This fixes the value jumping on a knob with a logarithmic scale. The power law is applied to
value - min, so its slope atminis either infinite or zero: a dB knob over-60..6moved 12% of its range on the first pixel of a drag, and a frequency knob over20..22000did not move at all for the first 12 pixels.exponentialScaleandcurveScalehave neither problem.skewScalestill behaves this way, since matching JUCE is the point of it. -
#146
fe74061Thanks @m1m0zzz! - RedesignPiano: the keyboard is no longer built from key components, and several fingers can play at once.Piano.Rootused to draw nothing on its own — a note only sounded becausePiano.WhiteKey/Piano.BlackKeycalledonPlayNotefrom an imperative handle, whichRootreached through an array of refs indexed by the order of its children. Custom children, or children in a different order, silently broke both the sound and the highlighting. The key geometry was kept in two places as well:Roothit-tested with its own white key width while each key drew itself with its ownwidthprop, so<Piano.WhiteKey width={60} />drew a key that responded somewhere else.Rootnow owns the keys and everything that is sounding. Per-key customization is two callbacks rather than a component per key.<Piano.RootnoteRange={{ first: noteNumber('C3'), last: noteNumber('B4') }}label={(note, { index }) => SHORTCUTS.HOME_ROW.keys[index]}keyProps={(note) => ({ 'data-in-scale': inScale(note, root, 'major') })}onPlayNote={(note) => synth.triggerAttack(noteName(note))}onStopNote={(note) => synth.triggerRelease(noteName(note))}/>Each key carries
data-note,data-note-key,data-activeandaria-disabled, so static styling needs no callback at all. The geometry of a key is applied after whateverkeyPropsreturns, so a key can no longer be drawn away from where it responds.Every way of playing a note — pointers, keyboard shortcuts,
playNote()from the ref — now goes through one instance that counts the sources holding each note, so a note stops only once the last of them lets go. Multi-touch and glissando work:createDraggainedmultiPointer, and@tremolo-ui/domgainedcreatePianoInput.Also new in
@tremolo-ui/functions: musical scales (scaleIntervals,inScale,scaleNotes) inmidi, and the piano geometry (PianoLayout,notePosition,noteAt,pianoWidth) inpiano.Breaking changes:
Piano.WhiteKey,Piano.BlackKey,Piano.KeyLabeland theKeyProps/KeyMethods/KeyLabelPropstypes are removed. UselabelandkeyProps, or plain CSS on.tremolo-piano-white-key/.tremolo-piano-black-keylabeltakes(note, state)rather than(note, index); the index isstate.index. A label of'',nullorundefinednow draws nothing rather than an empty boxgetNoteRangeArrayand theNoteRangetype moved to@tremolo-ui/functionswhiteNoteWidthis nowwhiteKeyWidth, and the deadblackNoteWidthprop is replaced byblackKeyWidthRatio/blackKeyHeightRatio/keyGapKeyboardShortcuts.flagsis removed. It was declared but never implemented;SHORTCUTS.HOME_ROW_NATURALcovers whatnaturalOnlywas for, by leaving an empty string where a note has no shortcut
-
#138
0e95f89Thanks @m1m0zzz! - UnifySlider,KnobandXYPadon one implementation.The three had diverged in how they treated
children, and that decided wherevaluelived.SliderandXYPadharvested props off their children and re-rendered their own markup, so the value could be passed down as props;Knobrendered children as written, so it had to keep the value in a store. All three now render children as written and read from a context that is derived during render, so there is no state to keep in sync.Breaking:
childrenis required. There is no default markup to fall back to, so adding one child no longer makes the rest disappear.<Slider.Root value={value} min={0} max={100} onChange={setValue}><Slider.Track><Slider.Thumb /></Slider.Track></Slider.Root>Breaking:
Slider.Thumbgoes insideSlider.Track, andXYPad.ThumbinsideXYPad.Area, rather than being siblings.Breaking:
XYPadtakes per-axis settings as[x, y]tuples mirroringSlider, instead ofxandyoption objects. A plain value applies to both axes.onChange,onDragStartandonDragEndnow receive a single[x, y]tuple. Per-axiswheelandkeyboardoptions are gone; the pad takes one of each.<XYPad.Root value={[x, y]} min={0} max={100} onChange={([x, y]) => ...}><XYPad.Area><XYPad.Thumb /></XYPad.Area></XYPad.Root>Breaking:
size,widthandheightare gone fromSlider.ThumbandXYPad.Thumb. The thumb size is the--thumb-sizeCSS variable on the root, which is also what the root uses to reserve space around the track or area, so the two can no longer disagree.Slider.TrackandXYPad.Areanow accept aref, composed with the one the root needs for normalizing the pointer position. -
#135
99d5db0Thanks @m1m0zzz! - Fix the Knob arcs overflowing their viewBox. The active and inactive lines were drawn at the full radius, so half of the stroke fell outside theviewBoxand was rescued withoverflow: visible. The radius is now inset by half the stroke width, and theoverflow: visiblerules are gone.Removed the unused
blockandoverflowVisibleprops fromKnob.SVGRoot.blockwas declared but never read, so passing it forwarded an invalid attribute to the<svg>element. -
#138
0e95f89Thanks @m1m0zzz! - GiveKnoba default size. Without thesizeprop the element collapsed to nothing, becausewidthandheightwere set toundefinedand the SVG inside has no intrinsic size. The default now lives in the CSS as--knob-size(50px) andsizestill overrides it. -
#141
95df589Thanks @m1m0zzz! - RebuildNumberInputon the compound pattern the other components already use, and give it one piece of state: the text being typed.The value is a
number, and what the input shows isformat(value)— except while the user is typing, when their own text stands until it is committed. Everything else is derived during render. The<input>is no longer rendered for you; compose it asNumberInput.InputField, the waySlider.Trackis composed.<NumberInput.Root value={value} min={0} max={100} units="Hz" onChange={setValue}><NumberInput.InputField /><NumberInput.Stepper><NumberInput.IncrementStepper /><NumberInput.DecrementStepper /></NumberInput.Stepper></NumberInput.Root>Keeping the typed text rather than reformatting it on every keystroke removes the cursor-position restoring that used to be needed, and lets a value be entered digit by digit: typing is never clamped, and the entry is brought into range when it is committed on blur or Enter.
Breaking changes to
NumberInput:childrenis required, andInputFieldhas to be composed in. There is no default markup to fall back tovalueis anumber; astringis no longer acceptedonChangereports(value: number)rather than(value, text)variant,activeColorandwrapperClassNameare gone.Rootis the wrapper, so itsclassNamestyles it, and the colors are CSS variables. The four variants are shown in the documentation as CSS to copykeepWithinRangeandclampValueOnBlurare oneclampValueprop (defaulttrue)selectWithFocusandblurOnEntermoved toInputField, asselectOnFocusandblurOnEnter. ItsonFocus/onBlurare plain DOM handlersStepperlostdynamic, and the steppers lostsize: both are styling, now the--stepper-icon-sizevariable and the demo CSS- the class names follow the parts:
tremolo-number-inputis the root andtremolo-number-input-fieldthe input, where the root used to betremolo-number-input-wrapper data-errorisdata-out-of-range
New in
NumberInput:skew,format/parsefor text the units cannot express, and dragging theStepperup and down to move the value, onestepeverydragpixels (1 by default). It needs no range to work against, so an unbounded input can be dragged too.The
<input>is now the tab stop and carries the spinbutton role and range, where the wrapper used to take focus and the input was skipped.Wheel control now only acts while the focus is inside the component, across
Slider,Knob,XYPadandNumberInput. Reacting on hover alone took the scroll away from the page, so passing over a control while reading changed its value.createWheelgained arequireFocusoption, andupdate().@tremolo-ui/functionsgainsformatValue,parseValue,selectUnitand theUnitstype, moved out of@tremolo-ui/reactso that they are available to any wrapper.It also gains
applyDelta, which moves a value by one wheel notch or arrow key press.Slider,Knob,XYPadandNumberInputeach had their own copy of this; they now share one, andAxisOptionsof@tremolo-ui/domextends itsValueRange, so a drag and a nudge describe their scaling the same way.Fixes
NumberInputthrowing"min" and "max" are requiredfromwheelorkeyboardinnormalizedmode wheneverminwas0, which a truthiness check rejected. -
#147
2f3bc8aThanks @m1m0zzz! - RebuildPointsEditoron plain React context, and make itswheel/keyboardprops do something.zustandis gone from the dependencies of@tremolo-ui/reactwith it.PointsEditorwas the last component keeping its settings in a zustand store. Everything in that store was derived from the props ofRoot, so it is now a plain context likeSliderandXYPadhave. Three bugs went with it:-
<PointsEditor.Root readonly>did nothing.Pointreadreadonlyfrom the context for its ARIA attribute, but guarded the drag with its own prop only, so the points still moved.readonlyanddisablednow reach everyPoint, and aPointcan still override either -
wheelandkeyboardwere declared, typed and documented onRoot, but nothing was ever wired to them. The arrow keys now nudge the focused point (y grows downwards, so ArrowUp moves it up) and the wheel moves it while it has focus, with shift selecting the x axis — the same conventions asXYPad.Rootsets the default and aPointcan override it,nullturning the input offUnlike a slider, an editor has several movable points, so the wheel listens on the container rather than on each point: a wheel event only reaches what the cursor is over, and a point is a 16px target. Every point sees the event and the focused one acts, so the wheel works anywhere over the editor. While a point has focus the editor takes the scroll, as
SliderandXYPadalready do -
Containercalled into the store while rendering, replacing the container element on every mount. It now composes the context ref with whatever ref you pass, so<PointsEditor.Container ref={...} />works
Breaking changes:
PointsEditorProps.gridis removed. It was never implemented and leaked onto the DOM as agridattributePointsEditorProps.childrenis now required, matching the other componentsusePointsEditorContextno longer requires a selector; calls that pass one are unaffected- A readonly point no longer shows a
grabcursor, since dragging it does nothing
useWheelgained atargetoption for this, listening on an element that is managed elsewhere instead of on the one its ref callback is attached to. -
Patch Changes
-
#137
68f05cbThanks @m1m0zzz! - Fix a slow drag not registering. Movement belowcreateDrag's threshold was discarded instead of carried over, and pointer coordinates are fractional, so dragging slowly moved less than a pixel per event and never reported anything. It now accumulates until it crosses the threshold.The threshold also now defaults to 0 in
createDrag, which restoresuseDragWithElement(used bySlider,XYPadandPointsEditor) to having no threshold at all, as it did before the move to@tremolo-ui/dom.useDragstill defaults to 1. -
Updated dependencies [
954dc53,27209dc,68f05cb,0646236,fe74061,95df589]:- @tremolo-ui/dom@0.5.0
- @tremolo-ui/functions@0.5.0
0.4.0
Minor Changes
-
#133
c0bda83Thanks @m1m0zzz! - Move pointer drag and wheel handling into@tremolo-ui/domascreateDragandcreateWheel.createDraguses Pointer Events only and relies on pointer capture, so the drag keeps working once the pointer leaves the element and no window level listeners are needed. It appliestouch-action: noneso that touch dragging does not scroll the page, and cancelsselectstartso that a long press does not start a text selection instead.The drag cursor (
externalStyles.cursor) is now applied to the dragged element rather than todocument.body. Pointer capture keeps it in effect once the pointer leaves the element, so there is no need to restyle the whole document, and long pressing no longer flashes a selection across the page.Fixes a bug where a drag starting at screen coordinate 0 (the top or left edge of the screen) never reported any movement, and a bug where
useDragWithElementpassed stale coordinates toonDragStart.SliderandXYPadnow move to the pointer position on pointer down, instead of waiting for the first movement.Breaking:
useDragnow returns a single ref callback instead of[refCallback, pointerDownHandler], anduseDragWithElementreturns{ refCallback, dragging }instead of{ refHandler, pointerDownHandler, dragging }. A newuseWheelhook is exported.Breaking:
DragObserverandWheelObserverare removed. They had become thin wrappers arounduseDraganduseWheel, which replace them.
Patch Changes
- Updated dependencies [
c0bda83]:- @tremolo-ui/dom@0.4.0
- @tremolo-ui/functions@0.4.0
0.3.0
Minor Changes
-
2a40e1cThanks @m1m0zzz! - Add@tremolo-ui/dom, a framework-agnostic DOM layer, and move the Web MIDI logic into it ascreateMIDIAccess/createMIDIMessage/createMIDIInput.useMIDIAccess,useMIDIMessageanduseMIDIInputkeep the same signatures and now call the core internally.
Patch Changes
- Updated dependencies [
2a40e1c]:- @tremolo-ui/dom@0.3.0
- @tremolo-ui/functions@0.3.0