@tremolo-ui/react 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compose-refs.cjs +72 -0
- package/dist/compose-refs.cjs.map +1 -0
- package/dist/compose-refs.d.cts +43 -0
- package/dist/compose-refs.d.cts.map +1 -0
- package/dist/compose-refs.d.ts +43 -0
- package/dist/compose-refs.d.ts.map +1 -0
- package/dist/compose-refs.js +70 -0
- package/dist/compose-refs.js.map +1 -0
- package/dist/index.cjs +1669 -541
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1571 -486
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1571 -486
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1640 -538
- package/dist/index.js.map +1 -1
- package/package.json +29 -27
- package/src/components/AnimationCanvas/index.tsx +115 -76
- package/src/components/DropZone/index.tsx +100 -0
- package/src/components/FileInput/Trigger.tsx +39 -0
- package/src/components/FileInput/context.tsx +24 -0
- package/src/components/FileInput/index.tsx +144 -0
- package/src/components/Knob/ActiveLine.tsx +7 -6
- package/src/components/Knob/InactiveLine.tsx +9 -10
- package/src/components/Knob/SVGRoot.tsx +5 -3
- package/src/components/Knob/Thumb.tsx +33 -11
- package/src/components/Knob/context.tsx +17 -1
- package/src/components/Knob/index.tsx +156 -60
- package/src/components/NumberInput/DecrementStepper.tsx +3 -10
- package/src/components/NumberInput/IncrementStepper.tsx +3 -10
- package/src/components/NumberInput/InputField.tsx +185 -108
- package/src/components/NumberInput/Stepper.tsx +120 -84
- package/src/components/NumberInput/StepperButton.tsx +100 -0
- package/src/components/NumberInput/context.tsx +34 -8
- package/src/components/NumberInput/index.tsx +270 -82
- package/src/components/Piano/index.tsx +363 -200
- package/src/components/PointsEditor/Background.tsx +13 -10
- package/src/components/PointsEditor/Container.tsx +83 -29
- package/src/components/PointsEditor/Point.tsx +241 -79
- package/src/components/PointsEditor/SelectionBox.tsx +48 -0
- package/src/components/PointsEditor/context.tsx +78 -10
- package/src/components/PointsEditor/index.tsx +440 -41
- package/src/components/Slider/Marks.tsx +48 -19
- package/src/components/Slider/MarksOption.tsx +64 -45
- package/src/components/Slider/Thumb.tsx +107 -55
- package/src/components/Slider/Track.tsx +66 -62
- package/src/components/Slider/context.tsx +4 -2
- package/src/components/Slider/index.tsx +136 -77
- package/src/components/Slider/type.ts +29 -9
- package/src/components/XYPad/Area.tsx +46 -41
- package/src/components/XYPad/Thumb.tsx +131 -64
- package/src/components/XYPad/context.tsx +4 -1
- package/src/components/XYPad/index.tsx +183 -53
- package/src/components/_util/Placement.tsx +68 -0
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +12 -0
- package/src/components/_util/css-length.ts +13 -0
- package/src/components/_util/decimal-digits.ts +23 -0
- package/src/components/_util/visually-hidden.ts +25 -0
- package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
- package/src/css-variables.ts +23 -0
- package/src/hooks/_internal/useCallbackRef.ts +3 -1
- package/src/hooks/_internal/useCheckSteps.ts +169 -0
- package/src/hooks/useAnimationFrame.ts +17 -7
- package/src/hooks/useDrag.ts +51 -12
- package/src/hooks/useDragValue.ts +71 -11
- package/src/hooks/useDropZone.ts +78 -0
- package/src/hooks/useEventListener.ts +10 -6
- package/src/hooks/useLongPress.ts +42 -8
- package/src/hooks/useMIDIAccess.ts +82 -11
- package/src/hooks/useMIDIInput.ts +45 -13
- package/src/hooks/useMIDIMessage.ts +23 -5
- package/src/hooks/useWheel.ts +4 -1
- package/src/index.ts +45 -25
- package/src/input-event.ts +32 -0
- package/dist/index.css +0 -501
- package/src/Globals.d.ts +0 -3
- package/src/components/Knob/index.css +0 -81
- package/src/components/NumberInput/index.css +0 -121
- package/src/components/NumberInput/stepperButton.tsx +0 -90
- package/src/components/Piano/index.css +0 -90
- package/src/components/PointsEditor/index.css +0 -35
- package/src/components/Slider/index.css +0 -125
- package/src/components/XYPad/index.css +0 -60
- package/src/components/_util/index.ts +0 -28
- package/src/styles/global.css +0 -19
- /package/src/components/Piano/{keyboardShortcuts.ts → keyboard-shortcuts.ts} +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createContext, ReactNode, useContext, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The subcomponent that established the current placement, if any.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
const PlacementContext = /* @__PURE__ */ createContext<string | null>(null)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Marks its children as being inside `name`, for {@link useCheckPlacement}.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export function Placement({
|
|
16
|
+
name,
|
|
17
|
+
children,
|
|
18
|
+
}: {
|
|
19
|
+
name: string
|
|
20
|
+
children: ReactNode
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<PlacementContext.Provider value={name}>
|
|
24
|
+
{children}
|
|
25
|
+
</PlacementContext.Provider>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Warn, in development only, when `child` is not rendered inside `parent`.
|
|
31
|
+
*
|
|
32
|
+
* A compound component renders its children exactly as they are composed, so a
|
|
33
|
+
* subcomponent put at the wrong level still renders — it only loses whatever
|
|
34
|
+
* its layout depended on, and does so silently. `Slider.Thumb` outside
|
|
35
|
+
* `Slider.Track` keeps its `position: absolute` but resolves it against the
|
|
36
|
+
* page rather than the track, and neither the build nor the tests notice.
|
|
37
|
+
*
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export function useCheckPlacement(child: string, parent: string) {
|
|
41
|
+
const found = useContext(PlacementContext)
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
try {
|
|
45
|
+
// `process.env.NODE_ENV` is written out here, inline and first, so that a
|
|
46
|
+
// bundler substituting it folds the comparison and drops this whole
|
|
47
|
+
// block — the message strings with it. Reading the flag through a helper
|
|
48
|
+
// or a module constant leaves the branch in the bundle instead: neither
|
|
49
|
+
// folds, which was measured with esbuild rather than assumed.
|
|
50
|
+
//
|
|
51
|
+
// The read is wrapped because `process` may not exist at all. The
|
|
52
|
+
// package is built with `platform: 'neutral'`, so a page loading the ESM
|
|
53
|
+
// straight from a CDN has no bundler to substitute it, and a bare
|
|
54
|
+
// reference would throw a `ReferenceError` mid-effect.
|
|
55
|
+
if (process.env.NODE_ENV !== 'production' && found !== parent) {
|
|
56
|
+
console.warn(
|
|
57
|
+
`[tremolo-ui] ${child} has to be rendered inside ${parent}` +
|
|
58
|
+
(found === null ? '.' : `, but it is inside ${found}.`) +
|
|
59
|
+
' It renders either way, so nothing fails — its position just' +
|
|
60
|
+
' comes out wrong.',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// No bundler substituted NODE_ENV, so there is no way to tell a
|
|
65
|
+
// production build from a development one. Say nothing rather than break.
|
|
66
|
+
}
|
|
67
|
+
}, [found, child, parent])
|
|
68
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, forwardRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import { visuallyHiddenStyle } from './visually-hidden'
|
|
4
|
+
|
|
5
|
+
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type' | 'style'>
|
|
6
|
+
|
|
7
|
+
export const VisuallyHiddenRangeInput = /* @__PURE__ */ forwardRef<
|
|
8
|
+
HTMLInputElement,
|
|
9
|
+
Props
|
|
10
|
+
>((props, ref) => (
|
|
11
|
+
<input {...props} ref={ref} type="range" style={visuallyHiddenStyle} />
|
|
12
|
+
))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A number as a CSS length, for a custom property.
|
|
3
|
+
*
|
|
4
|
+
* React appends `px` to a bare number for the properties it knows take a
|
|
5
|
+
* length, and a custom property is never one of them: `--size: 50` would come
|
|
6
|
+
* out as the invalid `50`. Anything already a string is passed through, so
|
|
7
|
+
* `'3rem'` and `'100%'` still work.
|
|
8
|
+
*/
|
|
9
|
+
export function cssLength(
|
|
10
|
+
value: number | string | undefined,
|
|
11
|
+
): string | undefined {
|
|
12
|
+
return typeof value === 'number' ? `${value}px` : value
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many digits a number carries after the decimal point.
|
|
3
|
+
*
|
|
4
|
+
* `Slider.Marks` builds its labels by multiplying an interval, which leaves
|
|
5
|
+
* binary debris in the last digits (`0.1 * 3` is `0.30000000000000004`). The
|
|
6
|
+
* count is what tells `toFixed` how far to round that back.
|
|
7
|
+
*
|
|
8
|
+
* The exponent form has to be handled separately: `String(1e-7)` is `'1e-7'`,
|
|
9
|
+
* which has no decimal point at all, so reading the text after the point
|
|
10
|
+
* reports no digits and the interval rounds away to whole numbers.
|
|
11
|
+
*/
|
|
12
|
+
export function decimalDigits(x: number): number {
|
|
13
|
+
if (!Number.isFinite(x)) return 0
|
|
14
|
+
|
|
15
|
+
const text = String(x)
|
|
16
|
+
const e = text.indexOf('e')
|
|
17
|
+
if (e === -1) return text.split('.')[1]?.length ?? 0
|
|
18
|
+
|
|
19
|
+
const fraction = text.slice(0, e).split('.')[1]?.length ?? 0
|
|
20
|
+
// A negative exponent pushes the point further right, a positive one pulls
|
|
21
|
+
// it back past the digits that are there.
|
|
22
|
+
return Math.max(0, fraction - Number(text.slice(e + 1)))
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Take an element out of sight while leaving it in the accessibility tree and
|
|
5
|
+
* in the tab order.
|
|
6
|
+
*
|
|
7
|
+
* `display: none` and `visibility: hidden` would remove it from both, and the
|
|
8
|
+
* native control underneath a headless component is what carries the ARIA and
|
|
9
|
+
* the keyboard behaviour. `pointer-events: none` is safe because nothing is
|
|
10
|
+
* ever clicked here directly: a `<label>` forwards its click, and a drag is
|
|
11
|
+
* handled by the part that is visible.
|
|
12
|
+
*/
|
|
13
|
+
export const visuallyHiddenStyle: CSSProperties = {
|
|
14
|
+
position: 'absolute',
|
|
15
|
+
width: 1,
|
|
16
|
+
height: 1,
|
|
17
|
+
padding: 0,
|
|
18
|
+
margin: -1,
|
|
19
|
+
overflow: 'hidden',
|
|
20
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
21
|
+
clipPath: 'inset(50%)',
|
|
22
|
+
whiteSpace: 'nowrap',
|
|
23
|
+
border: 0,
|
|
24
|
+
pointerEvents: 'none',
|
|
25
|
+
}
|
|
@@ -1,5 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* MIT License
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2022 WorkOS
|
|
5
|
+
*
|
|
6
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
* in the Software without restriction, including without limitation the rights
|
|
9
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
* furnished to do so, subject to the following conditions:
|
|
12
|
+
*
|
|
13
|
+
* The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
* copies or substantial portions of the Software.
|
|
15
|
+
*
|
|
16
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
* SOFTWARE.
|
|
23
|
+
*
|
|
24
|
+
*
|
|
25
|
+
* Copied from Radix UI Primitives.
|
|
26
|
+
* The source is here.
|
|
27
|
+
* https://github.com/radix-ui/primitives/blob/2bab24a811e45c7a83198659272097f6cfa5a165/packages/react/compose-refs/src/compose-refs.tsx
|
|
28
|
+
*/
|
|
3
29
|
|
|
4
30
|
import { useCallback } from 'react'
|
|
5
31
|
|
|
@@ -26,7 +52,7 @@ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
|
|
26
52
|
let hasCleanup = false
|
|
27
53
|
const cleanups = refs.map((ref) => {
|
|
28
54
|
const cleanup = setRef(ref, node)
|
|
29
|
-
if (!hasCleanup && typeof cleanup
|
|
55
|
+
if (!hasCleanup && typeof cleanup === 'function') {
|
|
30
56
|
hasCleanup = true
|
|
31
57
|
}
|
|
32
58
|
return cleanup
|
|
@@ -40,7 +66,7 @@ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
|
|
40
66
|
return () => {
|
|
41
67
|
for (let i = 0; i < cleanups.length; i++) {
|
|
42
68
|
const cleanup = cleanups[i]
|
|
43
|
-
if (typeof cleanup
|
|
69
|
+
if (typeof cleanup === 'function') {
|
|
44
70
|
cleanup()
|
|
45
71
|
} else {
|
|
46
72
|
setRef(refs[i], null)
|
|
@@ -56,7 +82,9 @@ function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
|
|
56
82
|
* Accepts callback refs and RefObject(s)
|
|
57
83
|
*/
|
|
58
84
|
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
|
59
|
-
//
|
|
85
|
+
// The ref list is variadic, so neither the callback nor its dependencies can
|
|
86
|
+
// be written as the literals the compiler rules expect.
|
|
87
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps, react/use-memo
|
|
60
88
|
return useCallback(composeRefs(...refs), refs)
|
|
61
89
|
}
|
|
62
90
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS custom properties, for a `style` object.
|
|
3
|
+
*
|
|
4
|
+
* React's `CSSProperties` has no key starting with `--`, so
|
|
5
|
+
* `style={{ '--color': 'red' }}` is refused as an unknown property even though
|
|
6
|
+
* the browser takes it. Every part accepts this alongside `CSSProperties`,
|
|
7
|
+
* which is how a variable the component or a theme reads is set inline.
|
|
8
|
+
*
|
|
9
|
+
* `T` names the custom properties the part itself writes or reads, without the
|
|
10
|
+
* leading `--`, so that an editor can suggest them. Any other `--` name is
|
|
11
|
+
* still accepted — a theme reads variables of its own, such as the colours of
|
|
12
|
+
* each kind of key.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* CSSVariables<'thickness' | 'length'>
|
|
16
|
+
* // { '--thickness'?: string | number, '--length'?: string | number,
|
|
17
|
+
* // [name: `--${string}`]: string | number | undefined }
|
|
18
|
+
*/
|
|
19
|
+
export type CSSVariables<T extends string = never> = {
|
|
20
|
+
[K in T as `--${K}`]?: string | number
|
|
21
|
+
} & {
|
|
22
|
+
[K in `--${string}`]?: string | number
|
|
23
|
+
}
|
|
@@ -16,6 +16,8 @@ export function useCallbackRef<Args extends unknown[], Return>(
|
|
|
16
16
|
callbackRef.current = callback
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
-
//
|
|
19
|
+
// `deps` comes from the caller, so it cannot be the array literal the
|
|
20
|
+
// compiler rules expect.
|
|
21
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps, react/use-memo
|
|
20
22
|
return useCallback((...args: Args) => callbackRef.current?.(...args), deps)
|
|
21
23
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyDelta,
|
|
5
|
+
type InputEventOption,
|
|
6
|
+
type ModifierState,
|
|
7
|
+
type ModifierValue,
|
|
8
|
+
} from '@tremolo-ui/dom'
|
|
9
|
+
import { linearScale, type ValueRange } from '@tremolo-ui/functions'
|
|
10
|
+
|
|
11
|
+
/** Positions probed across the travel. The ends are left out so that the
|
|
12
|
+
* clamp at `min` and `max` cannot be mistaken for a press that does nothing. */
|
|
13
|
+
const PROBES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
|
|
14
|
+
|
|
15
|
+
const NONE: ModifierState = {
|
|
16
|
+
shiftKey: false,
|
|
17
|
+
altKey: false,
|
|
18
|
+
ctrlKey: false,
|
|
19
|
+
metaKey: false,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MODIFIER_STATE: Record<string, ModifierState> = {
|
|
23
|
+
shift: { ...NONE, shiftKey: true },
|
|
24
|
+
alt: { ...NONE, altKey: true },
|
|
25
|
+
ctrl: { ...NONE, ctrlKey: true },
|
|
26
|
+
meta: { ...NONE, metaKey: true },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Each entry of a modifier-aware input option, as a name and held keys. */
|
|
30
|
+
function entries(
|
|
31
|
+
options: ModifierValue<InputEventOption>,
|
|
32
|
+
): { name: string; modifiers: ModifierState }[] {
|
|
33
|
+
if (Array.isArray(options)) return [{ name: '', modifiers: NONE }]
|
|
34
|
+
return Object.keys(options).map((key) => ({
|
|
35
|
+
name: key === 'default' ? '' : key,
|
|
36
|
+
modifiers: MODIFIER_STATE[key] ?? NONE,
|
|
37
|
+
}))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Outcome {
|
|
41
|
+
/** The press changed the value at least once across the travel. */
|
|
42
|
+
moved: boolean
|
|
43
|
+
/** The change reached the displayed text at least once. */
|
|
44
|
+
visible: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Press every entry of `options` at nine points along the travel and report
|
|
49
|
+
* whether anything came of it.
|
|
50
|
+
*
|
|
51
|
+
* Run against `applyDelta` itself rather than against a reading of `step`:
|
|
52
|
+
* the whole point is that the amount, the step and the scale interact, and
|
|
53
|
+
* the pipeline is the only thing that knows how.
|
|
54
|
+
*/
|
|
55
|
+
function probe(
|
|
56
|
+
options: ModifierValue<InputEventOption>,
|
|
57
|
+
range: ValueRange,
|
|
58
|
+
format?: (value: number) => string,
|
|
59
|
+
): Outcome {
|
|
60
|
+
const { min, max, scale = linearScale } = range
|
|
61
|
+
const outcome: Outcome = { moved: false, visible: false }
|
|
62
|
+
|
|
63
|
+
for (const { modifiers } of entries(options)) {
|
|
64
|
+
for (const position of PROBES) {
|
|
65
|
+
const value = scale.denormalize(position, min, max)
|
|
66
|
+
const up = applyDelta(value, 1, options, range, modifiers)
|
|
67
|
+
const down = applyDelta(value, -1, options, range, modifiers)
|
|
68
|
+
if (up !== value || down !== value) outcome.moved = true
|
|
69
|
+
if (!format) continue
|
|
70
|
+
const shown = format(value)
|
|
71
|
+
if (format(up) !== shown || format(down) !== shown) outcome.visible = true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return outcome
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CheckStepsOptions {
|
|
79
|
+
/** The component, for the message. */
|
|
80
|
+
component: string
|
|
81
|
+
/** The axis, for a component that has more than one. */
|
|
82
|
+
axis?: string
|
|
83
|
+
/**
|
|
84
|
+
* The range to probe, or `null` to check nothing. An unbounded input has no
|
|
85
|
+
* travel to sample, so `NumberInput` passes `null` when `min` and `max` are
|
|
86
|
+
* not both there.
|
|
87
|
+
*/
|
|
88
|
+
range: ValueRange | null
|
|
89
|
+
keyboard?: ModifierValue<InputEventOption> | null
|
|
90
|
+
wheel?: ModifierValue<InputEventOption> | null
|
|
91
|
+
/**
|
|
92
|
+
* How the value is displayed, where the component shows one. Called with
|
|
93
|
+
* probe values only, and only in development.
|
|
94
|
+
*/
|
|
95
|
+
format?: (value: number) => string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Warn, in development only, when a key press or a wheel notch cannot produce
|
|
100
|
+
* a change the user can see.
|
|
101
|
+
*
|
|
102
|
+
* Two settings that are each fine on their own can cancel out, and nothing
|
|
103
|
+
* fails when they do — the control simply sits there:
|
|
104
|
+
*
|
|
105
|
+
* - **`step` coarser than the amount.** `keyboard={['raw', 0.1]}` with
|
|
106
|
+
* `step={1}` rounds every press straight back to where it started
|
|
107
|
+
* - **the display coarser than the amount.** A `format` showing two decimals
|
|
108
|
+
* of a kHz value cannot show a press worth 1 Hz
|
|
109
|
+
*
|
|
110
|
+
* The second is only reported when the press is invisible at *every* point
|
|
111
|
+
* along the travel. A display that rounds is a deliberate choice and is
|
|
112
|
+
* normally right — it is being too coarse everywhere that makes it a mistake.
|
|
113
|
+
*
|
|
114
|
+
* @internal
|
|
115
|
+
*/
|
|
116
|
+
export function useCheckSteps({
|
|
117
|
+
component,
|
|
118
|
+
axis,
|
|
119
|
+
range,
|
|
120
|
+
keyboard,
|
|
121
|
+
wheel,
|
|
122
|
+
format,
|
|
123
|
+
}: CheckStepsOptions) {
|
|
124
|
+
const { min, max, step, scale } = range ?? {}
|
|
125
|
+
const where = axis ? `${component} (${axis})` : component
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
try {
|
|
129
|
+
// Inline and first, so a bundler folds the comparison and drops the
|
|
130
|
+
// whole block — the probing and the message strings with it. See
|
|
131
|
+
// `useCheckPlacement` for why it cannot go through a helper, and why
|
|
132
|
+
// it is wrapped.
|
|
133
|
+
if (process.env.NODE_ENV === 'production') return
|
|
134
|
+
if (!range || min === undefined || max === undefined || !(min < max)) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const [name, options] of [
|
|
139
|
+
['keyboard', keyboard],
|
|
140
|
+
['wheel', wheel],
|
|
141
|
+
] as const) {
|
|
142
|
+
if (!options) continue
|
|
143
|
+
const { moved, visible } = probe(options, range, format)
|
|
144
|
+
if (!moved) {
|
|
145
|
+
console.warn(
|
|
146
|
+
`[tremolo-ui] ${where}: \`${name}\` cannot move the value.` +
|
|
147
|
+
(step !== undefined
|
|
148
|
+
? ` Each press is smaller than \`step\` (${step}), so it rounds`
|
|
149
|
+
: ' Each press rounds') +
|
|
150
|
+
' straight back to where it started.',
|
|
151
|
+
)
|
|
152
|
+
} else if (format && !visible) {
|
|
153
|
+
console.warn(
|
|
154
|
+
`[tremolo-ui] ${where}: \`${name}\` moves the value, but \`format\`` +
|
|
155
|
+
' shows the same text before and after, everywhere in the range.' +
|
|
156
|
+
' The display is too coarse for it to be seen.',
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// No bundler substituted NODE_ENV, so a production build cannot be told
|
|
162
|
+
// from a development one. Say nothing rather than break.
|
|
163
|
+
}
|
|
164
|
+
// `range` and `format` are rebuilt on most renders, so the effect is keyed
|
|
165
|
+
// on what actually decides the outcome. `format` is left out on purpose:
|
|
166
|
+
// an inline arrow function would make this run every render.
|
|
167
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
168
|
+
}, [where, min, max, step, scale, keyboard, wheel])
|
|
169
|
+
}
|
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
import { DependencyList,
|
|
1
|
+
import { DependencyList, useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useCallbackRef } from './_internal/useCallbackRef'
|
|
2
4
|
|
|
3
5
|
export function useAnimationFrame(
|
|
4
6
|
callback = () => {},
|
|
5
7
|
deps: DependencyList = [],
|
|
6
8
|
) {
|
|
7
9
|
const reqIdRef = useRef(-1)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// Read through a ref rather than depended on: `useAnimationFrame(() => ...)`
|
|
11
|
+
// is a new function on every render, and a callback that renders would then
|
|
12
|
+
// cancel and re-schedule its own loop on every frame. What restarts the loop
|
|
13
|
+
// is the caller's `deps`, and nothing else.
|
|
14
|
+
const runCallback = useCallbackRef(callback)
|
|
13
15
|
|
|
14
16
|
useEffect(() => {
|
|
17
|
+
// Kept inside the effect: as a `useCallback` the loop would have to
|
|
18
|
+
// reference itself before it is declared, which the compiler rules reject.
|
|
19
|
+
const loop = () => {
|
|
20
|
+
reqIdRef.current = requestAnimationFrame(loop)
|
|
21
|
+
runCallback()
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
reqIdRef.current = requestAnimationFrame(loop)
|
|
16
25
|
return () => cancelAnimationFrame(reqIdRef.current)
|
|
17
|
-
|
|
26
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
27
|
+
}, [runCallback, ...deps])
|
|
18
28
|
}
|
package/src/hooks/useDrag.ts
CHANGED
|
@@ -1,22 +1,50 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
|
|
3
|
-
import { createDrag } from '@tremolo-ui/dom'
|
|
3
|
+
import { createDrag, type DragState } from '@tremolo-ui/dom'
|
|
4
4
|
|
|
5
5
|
import { useCallbackRef } from './_internal/useCallbackRef'
|
|
6
6
|
|
|
7
|
-
interface
|
|
7
|
+
export interface UseDragOptions {
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Pixels the pointer has to travel before the drag counts as one.
|
|
10
|
+
*
|
|
11
|
+
* A click moves the pointer by a pixel or two, so without a threshold a
|
|
12
|
+
* double click reports a drag between the two presses.
|
|
13
|
+
*
|
|
14
|
+
* @default 1
|
|
11
15
|
*/
|
|
12
16
|
threshold?: number
|
|
13
17
|
|
|
14
18
|
/** CSS cursor to show while dragging. Applied to the element itself. */
|
|
15
19
|
cursor?: string
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Hide the pointer and read its movement directly, instead of following it
|
|
23
|
+
* around the screen.
|
|
24
|
+
*
|
|
25
|
+
* @see DragOptions.pointerLock
|
|
26
|
+
* @default false
|
|
27
|
+
*/
|
|
28
|
+
pointerLock?: boolean
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Called on every move once the drag has started, with the pointer position
|
|
32
|
+
* relative to the element and how far it moved since the last call.
|
|
33
|
+
*
|
|
34
|
+
* @param state the whole drag, for anything the four numbers leave out —
|
|
35
|
+
* the pointer event and its modifier keys, most of all.
|
|
36
|
+
*/
|
|
37
|
+
onDrag?: (
|
|
38
|
+
x: number,
|
|
39
|
+
y: number,
|
|
40
|
+
deltaX: number,
|
|
41
|
+
deltaY: number,
|
|
42
|
+
state: DragState,
|
|
43
|
+
) => void
|
|
44
|
+
/** Called once the pointer has moved past `threshold`, not on pointerdown. */
|
|
45
|
+
onDragStart?: (state: DragState) => void
|
|
46
|
+
/** Called when the pointer is released, only if the drag ever started. */
|
|
47
|
+
onDragEnd?: (state: DragState) => void
|
|
20
48
|
}
|
|
21
49
|
|
|
22
50
|
/**
|
|
@@ -27,10 +55,11 @@ interface UseDragProps {
|
|
|
27
55
|
export function useDrag<T extends Element>({
|
|
28
56
|
threshold = 1,
|
|
29
57
|
cursor,
|
|
58
|
+
pointerLock,
|
|
30
59
|
onDrag,
|
|
31
60
|
onDragStart,
|
|
32
61
|
onDragEnd,
|
|
33
|
-
}:
|
|
62
|
+
}: UseDragOptions): (node: T | null) => void {
|
|
34
63
|
const dragHandler = useCallbackRef(onDrag)
|
|
35
64
|
const dragStartHandler = useCallbackRef(onDragStart)
|
|
36
65
|
const dragEndHandler = useCallbackRef(onDragEnd)
|
|
@@ -46,13 +75,23 @@ export function useDrag<T extends Element>({
|
|
|
46
75
|
const instance = createDrag(node, {
|
|
47
76
|
threshold,
|
|
48
77
|
cursor,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
pointerLock,
|
|
79
|
+
onDragStart: (state) => dragStartHandler(state),
|
|
80
|
+
onDrag: (state) =>
|
|
81
|
+
dragHandler(state.x, state.y, state.deltaX, state.deltaY, state),
|
|
82
|
+
onDragEnd: (state) => dragEndHandler(state),
|
|
52
83
|
})
|
|
53
84
|
|
|
54
85
|
return () => instance.destroy()
|
|
55
|
-
}, [
|
|
86
|
+
}, [
|
|
87
|
+
node,
|
|
88
|
+
threshold,
|
|
89
|
+
cursor,
|
|
90
|
+
pointerLock,
|
|
91
|
+
dragHandler,
|
|
92
|
+
dragStartHandler,
|
|
93
|
+
dragEndHandler,
|
|
94
|
+
])
|
|
56
95
|
|
|
57
96
|
return setNode
|
|
58
97
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
elementMapping,
|
|
6
6
|
relativeMapping,
|
|
7
7
|
type AxisOptions,
|
|
8
|
+
type DragState,
|
|
8
9
|
type DragValueInstance,
|
|
9
10
|
type XY,
|
|
10
11
|
type XYInput,
|
|
@@ -36,17 +37,65 @@ export interface UseDragValueOptions {
|
|
|
36
37
|
* @default 100
|
|
37
38
|
*/
|
|
38
39
|
pixelRange?: XYInput<number>
|
|
40
|
+
/**
|
|
41
|
+
* How much the movement counts, read on every move. `0.1` makes the same
|
|
42
|
+
* movement cover a tenth of the range, which is what a fine-adjustment
|
|
43
|
+
* modifier wants.
|
|
44
|
+
*
|
|
45
|
+
* With `baseElementRef` the value is normally the position pointed at, so
|
|
46
|
+
* anything but `1` turns the mapping relative and leaves the pointer and the
|
|
47
|
+
* value apart for the rest of the drag.
|
|
48
|
+
*
|
|
49
|
+
* @see relativeMapping
|
|
50
|
+
* @see elementMapping
|
|
51
|
+
*/
|
|
52
|
+
sensitivity?: (state: DragState) => number
|
|
39
53
|
|
|
40
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Move the value on pointerdown, before the pointer has moved at all. With
|
|
56
|
+
* `baseElementRef` that makes a press jump the value to where it landed.
|
|
57
|
+
*
|
|
58
|
+
* @see DragValueOptions.updateOnPointerDown
|
|
59
|
+
*/
|
|
41
60
|
updateOnPointerDown?: boolean
|
|
42
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Pixels the pointer has to travel before the drag counts as one. The
|
|
63
|
+
* default is 0, so the value follows the very first move.
|
|
64
|
+
*
|
|
65
|
+
* @default 0
|
|
66
|
+
*/
|
|
43
67
|
threshold?: number
|
|
44
68
|
/** CSS cursor to show while dragging. Applied to the element itself. */
|
|
45
69
|
cursor?: string
|
|
70
|
+
/**
|
|
71
|
+
* Hide the pointer and read its movement directly, instead of following it
|
|
72
|
+
* around the screen. Only with `getValue`: there is no pointer position to
|
|
73
|
+
* normalize against a `baseElementRef` while the pointer is locked.
|
|
74
|
+
*
|
|
75
|
+
* @see DragOptions.pointerLock
|
|
76
|
+
* @default false
|
|
77
|
+
*/
|
|
78
|
+
pointerLock?: boolean
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether a pointerdown starts a drag at all, before the pointer is
|
|
81
|
+
* captured.
|
|
82
|
+
*
|
|
83
|
+
* @see DragOptions.shouldStart
|
|
84
|
+
*/
|
|
85
|
+
shouldStart?: (event: PointerEvent) => boolean
|
|
46
86
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Called with the new value whenever the drag moves it. The hook holds no
|
|
89
|
+
* value of its own, so this is where it is stored.
|
|
90
|
+
*
|
|
91
|
+
* @param state the whole drag, for anything the value leaves out — the
|
|
92
|
+
* pointer event and its modifier keys, most of all.
|
|
93
|
+
*/
|
|
94
|
+
onChange?: (value: XY<number>, state: DragState) => void
|
|
95
|
+
/** Called once the drag starts, with the value it starts from. */
|
|
96
|
+
onDragStart?: (value: XY<number>, state: DragState) => void
|
|
97
|
+
/** Called when the pointer is released, with the value it ends on. */
|
|
98
|
+
onDragEnd?: (value: XY<number>, state: DragState) => void
|
|
50
99
|
}
|
|
51
100
|
|
|
52
101
|
/**
|
|
@@ -64,6 +113,7 @@ export function useDragValue<T extends Element>(
|
|
|
64
113
|
updateOnPointerDown,
|
|
65
114
|
threshold,
|
|
66
115
|
cursor,
|
|
116
|
+
pointerLock,
|
|
67
117
|
} = options
|
|
68
118
|
|
|
69
119
|
if (!baseElementRef && !options.getValue) {
|
|
@@ -98,22 +148,31 @@ export function useDragValue<T extends Element>(
|
|
|
98
148
|
const instance = createDragValue(node, {
|
|
99
149
|
axis: latest.current.axis,
|
|
100
150
|
mapping: baseElementRef
|
|
101
|
-
? elementMapping(() => baseElementRef.current
|
|
151
|
+
? elementMapping(() => baseElementRef.current, {
|
|
152
|
+
// Read through the ref so that a changed setting reaches a drag
|
|
153
|
+
// already in progress.
|
|
154
|
+
sensitivity: (state) => latest.current.sensitivity?.(state) ?? 1,
|
|
155
|
+
})
|
|
102
156
|
: relativeMapping({
|
|
103
157
|
pixelRange: [pixelRangeX ?? 100, pixelRangeY ?? 100],
|
|
158
|
+
// Read through the ref so that a changed setting reaches a drag
|
|
159
|
+
// already in progress.
|
|
160
|
+
sensitivity: (state) => latest.current.sensitivity?.(state) ?? 1,
|
|
104
161
|
}),
|
|
105
162
|
getValue: () => valueGetter() ?? [0, 0],
|
|
106
163
|
updateOnPointerDown: latest.current.updateOnPointerDown,
|
|
107
164
|
threshold: latest.current.threshold,
|
|
108
165
|
cursor: latest.current.cursor,
|
|
109
|
-
|
|
110
|
-
|
|
166
|
+
pointerLock: latest.current.pointerLock,
|
|
167
|
+
shouldStart: (event) => latest.current.shouldStart?.(event) ?? true,
|
|
168
|
+
onChange: (value, state) => changeHandler(value, state),
|
|
169
|
+
onDragStart: (value, state) => {
|
|
111
170
|
setDragging(true)
|
|
112
|
-
dragStartHandler(value)
|
|
171
|
+
dragStartHandler(value, state)
|
|
113
172
|
},
|
|
114
|
-
onDragEnd: (value) => {
|
|
173
|
+
onDragEnd: (value, state) => {
|
|
115
174
|
setDragging(false)
|
|
116
|
-
dragEndHandler(value)
|
|
175
|
+
dragEndHandler(value, state)
|
|
117
176
|
},
|
|
118
177
|
})
|
|
119
178
|
instanceRef.current = instance
|
|
@@ -142,6 +201,7 @@ export function useDragValue<T extends Element>(
|
|
|
142
201
|
updateOnPointerDown,
|
|
143
202
|
threshold,
|
|
144
203
|
cursor,
|
|
204
|
+
pointerLock,
|
|
145
205
|
})
|
|
146
206
|
})
|
|
147
207
|
|