@tremolo-ui/react 0.6.0 → 0.8.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/index.cjs +522 -997
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +601 -329
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +601 -329
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +487 -953
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/DropZone/index.tsx +101 -0
- package/src/components/FileInput/Trigger.tsx +39 -0
- package/src/components/FileInput/context.tsx +24 -0
- package/src/components/FileInput/index.tsx +139 -0
- package/src/components/Knob/ActiveLine.tsx +5 -3
- package/src/components/Knob/InactiveLine.tsx +6 -4
- package/src/components/Knob/SVGRoot.tsx +3 -3
- package/src/components/Knob/Thumb.tsx +3 -2
- package/src/components/Knob/context.tsx +3 -74
- package/src/components/Knob/index.tsx +27 -21
- package/src/components/NumberInput/DecrementStepper.tsx +2 -13
- package/src/components/NumberInput/IncrementStepper.tsx +2 -14
- package/src/components/NumberInput/InputField.tsx +17 -38
- package/src/components/NumberInput/Stepper.tsx +44 -107
- package/src/components/NumberInput/StepperButton.tsx +16 -12
- package/src/components/NumberInput/context.tsx +8 -8
- package/src/components/NumberInput/index.tsx +41 -81
- package/src/components/Piano/index.tsx +39 -149
- package/src/components/PointsEditor/Background.tsx +2 -8
- package/src/components/PointsEditor/Container.tsx +17 -34
- package/src/components/PointsEditor/Point.tsx +41 -86
- package/src/components/PointsEditor/SelectionBox.tsx +2 -8
- package/src/components/PointsEditor/context.tsx +10 -61
- package/src/components/PointsEditor/index.tsx +31 -315
- package/src/components/Slider/Marks.tsx +8 -13
- package/src/components/Slider/MarksOption.tsx +11 -17
- package/src/components/Slider/Thumb.tsx +9 -15
- package/src/components/Slider/Track.tsx +7 -13
- package/src/components/Slider/index.tsx +33 -39
- package/src/components/XYPad/Area.tsx +26 -53
- package/src/components/XYPad/Thumb.tsx +8 -12
- package/src/components/XYPad/context.tsx +1 -3
- package/src/components/XYPad/index.tsx +35 -50
- package/src/components/_util/VisuallyHiddenRangeInput.tsx +3 -15
- package/src/css-variables.ts +23 -0
- package/src/hooks/_internal/useCheckSteps.ts +9 -147
- package/src/hooks/useDrag.ts +13 -4
- package/src/hooks/useDragValue.ts +17 -2
- package/src/hooks/useDropZone.ts +78 -0
- package/src/hooks/useLongPress.ts +35 -51
- package/src/hooks/useMIDIAccess.ts +0 -8
- package/src/hooks/useMIDIInput.ts +0 -3
- package/src/hooks/useWheel.ts +4 -1
- package/src/index.ts +14 -36
- package/src/components/Piano/keyboard-shortcuts.ts +0 -61
- package/src/components/Slider/type.ts +0 -44
- package/src/components/_util/css-length.ts +0 -13
- package/src/input-event.ts +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tremolo-ui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "React component library for audio app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@testing-library/react": "16.3.3",
|
|
31
31
|
"@types/react": "19.3.0",
|
|
32
32
|
"@types/react-dom": "19.3.0",
|
|
33
|
-
"jotai": "
|
|
33
|
+
"jotai": "3.0.0",
|
|
34
34
|
"react": "19.3.0",
|
|
35
35
|
"react-dom": "19.3.0",
|
|
36
36
|
"storybook": "10.6.0",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"react-dom": "^18 || ^19"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@tremolo-ui/dom": "^0.
|
|
45
|
-
"@tremolo-ui/functions": "^0.
|
|
44
|
+
"@tremolo-ui/dom": "^0.8.0",
|
|
45
|
+
"@tremolo-ui/functions": "^0.8.0"
|
|
46
46
|
},
|
|
47
47
|
"files": [
|
|
48
48
|
"dist",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useComposedRefs } from '../../compose-refs'
|
|
4
|
+
import { useDropZone } from '../../hooks/useDropZone'
|
|
5
|
+
|
|
6
|
+
export interface DropZoneProps {
|
|
7
|
+
/**
|
|
8
|
+
* Which files to take, written the way the `accept` attribute of a file
|
|
9
|
+
* input is: a comma separated list of extensions (`.wav`), MIME types
|
|
10
|
+
* (`audio/wav`) and type groups (`audio/*`).
|
|
11
|
+
*
|
|
12
|
+
* What does not match goes to `onReject` rather than `onDrop`. While the
|
|
13
|
+
* drag is still in the air the element is marked `[data-invalid]`, as far as
|
|
14
|
+
* the browser lets it be told apart — a rule written as an extension cannot
|
|
15
|
+
* be decided until the drop.
|
|
16
|
+
*/
|
|
17
|
+
accept?: string
|
|
18
|
+
/**
|
|
19
|
+
* Take more than one file from a single drop. With it off, only the first
|
|
20
|
+
* accepted file is reported, as a file input without `multiple` does.
|
|
21
|
+
*
|
|
22
|
+
* @default false
|
|
23
|
+
*/
|
|
24
|
+
multiple?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* Refuse the drop. The drag is still swallowed rather than let through: an
|
|
27
|
+
* unhandled drop makes the browser leave the page and open the file.
|
|
28
|
+
*
|
|
29
|
+
* @default false
|
|
30
|
+
*/
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Called with the dropped files that match `accept`.
|
|
35
|
+
*
|
|
36
|
+
* @param event the drop, for anything the files leave out — where on the
|
|
37
|
+
* element it landed, most of all.
|
|
38
|
+
*/
|
|
39
|
+
onDrop?: (files: File[], event: DragEvent) => void
|
|
40
|
+
/**
|
|
41
|
+
* Called with the dropped files that do not match `accept`, so that the
|
|
42
|
+
* reason can be shown. It comes after `onDrop` for the same drop, so a list
|
|
43
|
+
* of rejected files can be cleared in `onDrop` and filled here.
|
|
44
|
+
*/
|
|
45
|
+
onReject?: (files: File[], event: DragEvent) => void
|
|
46
|
+
|
|
47
|
+
/** What the zone shows. */
|
|
48
|
+
children: ReactNode
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Props = DropZoneProps &
|
|
52
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof DropZoneProps>
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An area that takes files dropped onto it.
|
|
56
|
+
*
|
|
57
|
+
* To make an element you already have into a drop target, without wrapping it
|
|
58
|
+
* in anything, use the `useDropZone` hook this is built on.
|
|
59
|
+
*/
|
|
60
|
+
export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
61
|
+
(
|
|
62
|
+
{
|
|
63
|
+
accept,
|
|
64
|
+
multiple = false,
|
|
65
|
+
disabled = false,
|
|
66
|
+
onDrop,
|
|
67
|
+
onReject,
|
|
68
|
+
children,
|
|
69
|
+
...props
|
|
70
|
+
},
|
|
71
|
+
ref,
|
|
72
|
+
) => {
|
|
73
|
+
const { refCallback, over, invalid } = useDropZone<HTMLDivElement>({
|
|
74
|
+
accept,
|
|
75
|
+
multiple,
|
|
76
|
+
disabled,
|
|
77
|
+
onDrop,
|
|
78
|
+
onReject,
|
|
79
|
+
})
|
|
80
|
+
const composedRef = useComposedRefs<HTMLDivElement>(ref, refCallback)
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={composedRef}
|
|
85
|
+
data-dragover={over ? '' : undefined}
|
|
86
|
+
data-invalid={invalid ? '' : undefined}
|
|
87
|
+
data-disabled={disabled ? '' : undefined}
|
|
88
|
+
{...props}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* An area that takes files dropped onto it.
|
|
98
|
+
*/
|
|
99
|
+
export const DropZone = {
|
|
100
|
+
Root,
|
|
101
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useFileInputContext } from './context'
|
|
4
|
+
|
|
5
|
+
export interface FileInputTriggerProps {
|
|
6
|
+
/** What the trigger reads. It is the accessible name of the file input. */
|
|
7
|
+
children: ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type Props = FileInputTriggerProps &
|
|
11
|
+
Omit<
|
|
12
|
+
ComponentPropsWithoutRef<'label'>,
|
|
13
|
+
keyof FileInputTriggerProps | 'htmlFor'
|
|
14
|
+
>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Opens the file picker, and names the input for a screen reader.
|
|
18
|
+
*
|
|
19
|
+
* A `<label>` rather than a `<button>`: the browser forwards the click to the
|
|
20
|
+
* input on its own, the input stays the one thing in the tab order, and its
|
|
21
|
+
* accessible name comes from this text without an `aria-label` to keep in
|
|
22
|
+
* step. A disabled input ignores the click, so nothing has to be intercepted.
|
|
23
|
+
*/
|
|
24
|
+
export const Trigger = /* @__PURE__ */ forwardRef<HTMLLabelElement, Props>(
|
|
25
|
+
({ children, ...props }, ref) => {
|
|
26
|
+
const { inputId, disabled } = useFileInputContext()
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<label
|
|
30
|
+
ref={ref}
|
|
31
|
+
htmlFor={inputId}
|
|
32
|
+
data-disabled={disabled ? '' : undefined}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</label>
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
export type FileInputContextValue = {
|
|
4
|
+
/** The id of the hidden `<input type="file">`, for `Trigger`'s `htmlFor`. */
|
|
5
|
+
inputId: string
|
|
6
|
+
disabled: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const FileInputContext =
|
|
10
|
+
/* @__PURE__ */ createContext<FileInputContextValue | null>(null)
|
|
11
|
+
|
|
12
|
+
export const FileInputProvider = FileInputContext.Provider
|
|
13
|
+
|
|
14
|
+
export function useFileInputContext(): FileInputContextValue
|
|
15
|
+
export function useFileInputContext<T>(
|
|
16
|
+
selector: (state: FileInputContextValue) => T,
|
|
17
|
+
): T
|
|
18
|
+
export function useFileInputContext<T>(
|
|
19
|
+
selector?: (state: FileInputContextValue) => T,
|
|
20
|
+
) {
|
|
21
|
+
const context = useContext(FileInputContext)
|
|
22
|
+
if (!context) throw new Error('Missing FileInputContext.Provider in the tree')
|
|
23
|
+
return selector ? selector(context) : context
|
|
24
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeEvent,
|
|
3
|
+
ComponentPropsWithoutRef,
|
|
4
|
+
forwardRef,
|
|
5
|
+
ReactNode,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from 'react'
|
|
9
|
+
|
|
10
|
+
import { partitionByAccept, visuallyHiddenStyle } from '@tremolo-ui/dom'
|
|
11
|
+
|
|
12
|
+
import { FileInputProvider } from './context'
|
|
13
|
+
import { Trigger } from './Trigger'
|
|
14
|
+
|
|
15
|
+
export interface FileInputProps {
|
|
16
|
+
/**
|
|
17
|
+
* Which files to take, written the way the HTML attribute is: a comma
|
|
18
|
+
* separated list of extensions (`.wav`), MIME types (`audio/wav`) and type
|
|
19
|
+
* groups (`audio/*`).
|
|
20
|
+
*
|
|
21
|
+
* **The browser treats it as a hint to the picker, not a rule.** A person
|
|
22
|
+
* can switch the picker to "All Files", so what arrives is checked again
|
|
23
|
+
* here, and anything that does not match goes to `onReject` instead.
|
|
24
|
+
*/
|
|
25
|
+
accept?: string
|
|
26
|
+
/**
|
|
27
|
+
* Let more than one file be picked at a time.
|
|
28
|
+
* @default false
|
|
29
|
+
*/
|
|
30
|
+
multiple?: boolean
|
|
31
|
+
/**
|
|
32
|
+
* Refuse to open the picker, and mark every part with `[data-disabled]`.
|
|
33
|
+
* @default false
|
|
34
|
+
*/
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Called with the files that were picked and match `accept`.
|
|
39
|
+
*
|
|
40
|
+
* It is not called at all when the picker is dismissed, or when every file
|
|
41
|
+
* picked was rejected — an empty array never arrives, so there is no need to
|
|
42
|
+
* tell "nothing was chosen" from "the choice was cleared".
|
|
43
|
+
*/
|
|
44
|
+
onChange?: (files: File[]) => void
|
|
45
|
+
/**
|
|
46
|
+
* Called with the files that were picked and do not match `accept`, so that
|
|
47
|
+
* the reason can be shown. Given alongside `onChange` when a selection held
|
|
48
|
+
* both, and after it, so a list of rejected files can be cleared in
|
|
49
|
+
* `onChange` and filled here.
|
|
50
|
+
*/
|
|
51
|
+
onReject?: (files: File[]) => void
|
|
52
|
+
|
|
53
|
+
/** Normally a `FileInput.Trigger`, and whatever shows what was picked. */
|
|
54
|
+
children: ReactNode
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Props = FileInputProps &
|
|
58
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof FileInputProps>
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Hands over the files a person picked.
|
|
62
|
+
*
|
|
63
|
+
* The `<input type="file">` is here, kept out of sight but in the tab order,
|
|
64
|
+
* because it is the control: it takes the focus, opens the picker on Enter,
|
|
65
|
+
* and carries the accessible name. What is on screen is whatever the children
|
|
66
|
+
* draw.
|
|
67
|
+
*/
|
|
68
|
+
export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
69
|
+
(
|
|
70
|
+
{
|
|
71
|
+
accept,
|
|
72
|
+
multiple = false,
|
|
73
|
+
disabled = false,
|
|
74
|
+
onChange,
|
|
75
|
+
onReject,
|
|
76
|
+
children,
|
|
77
|
+
// Pulled out of `props` so that they land on the input rather than on
|
|
78
|
+
// the wrapper: it is the control, and a description is only announced
|
|
79
|
+
// from the element that takes the focus. No declaration in
|
|
80
|
+
// `FileInputProps` is needed for this — they are already on the div
|
|
81
|
+
// attributes it is merged with, with the same types.
|
|
82
|
+
'aria-label': ariaLabel,
|
|
83
|
+
'aria-labelledby': ariaLabelledby,
|
|
84
|
+
'aria-describedby': ariaDescribedby,
|
|
85
|
+
...props
|
|
86
|
+
},
|
|
87
|
+
ref,
|
|
88
|
+
) => {
|
|
89
|
+
const inputId = useId()
|
|
90
|
+
|
|
91
|
+
const context = useMemo(() => ({ inputId, disabled }), [inputId, disabled])
|
|
92
|
+
|
|
93
|
+
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
|
94
|
+
const { accepted, rejected } = partitionByAccept(
|
|
95
|
+
Array.from(event.target.files ?? []),
|
|
96
|
+
accept,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// Picking the same file twice fires no second change event while the
|
|
100
|
+
// value is still on the input, so it is cleared as soon as it is read.
|
|
101
|
+
// Nothing is lost: the files are already in hand.
|
|
102
|
+
event.target.value = ''
|
|
103
|
+
|
|
104
|
+
if (accepted.length > 0) onChange?.(accepted)
|
|
105
|
+
if (rejected.length > 0) onReject?.(rejected)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<FileInputProvider value={context}>
|
|
110
|
+
<div ref={ref} data-disabled={disabled ? '' : undefined} {...props}>
|
|
111
|
+
<input
|
|
112
|
+
id={inputId}
|
|
113
|
+
type="file"
|
|
114
|
+
accept={accept}
|
|
115
|
+
multiple={multiple}
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
style={visuallyHiddenStyle}
|
|
118
|
+
aria-label={ariaLabel}
|
|
119
|
+
aria-labelledby={ariaLabelledby}
|
|
120
|
+
aria-describedby={ariaDescribedby}
|
|
121
|
+
onChange={handleChange}
|
|
122
|
+
/>
|
|
123
|
+
{children}
|
|
124
|
+
</div>
|
|
125
|
+
</FileInputProvider>
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Picks files, and hands them over as `File`s.
|
|
132
|
+
*/
|
|
133
|
+
export const FileInput = {
|
|
134
|
+
Root,
|
|
135
|
+
Trigger,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { useFileInputContext, type FileInputContextValue } from './context'
|
|
139
|
+
export { type FileInputTriggerProps } from './Trigger'
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
+
import { knobArcPath, knobArcRadius } from '@tremolo-ui/dom'
|
|
4
|
+
|
|
3
5
|
import { useCheckPlacement } from '../_util/Placement'
|
|
4
6
|
|
|
5
|
-
import {
|
|
7
|
+
import { useKnobContext } from './context'
|
|
6
8
|
|
|
7
9
|
export function ActiveLine({
|
|
8
10
|
stroke = 'currentColor',
|
|
@@ -15,12 +17,12 @@ export function ActiveLine({
|
|
|
15
17
|
const r2 = useKnobContext((s) => s.r2)
|
|
16
18
|
const r3 = useKnobContext((s) => s.r3)
|
|
17
19
|
|
|
18
|
-
const radius =
|
|
20
|
+
const radius = knobArcRadius(strokeWidth)
|
|
19
21
|
|
|
20
22
|
return (
|
|
21
23
|
<path
|
|
22
24
|
className={className}
|
|
23
|
-
d={
|
|
25
|
+
d={knobArcPath(r2, r3, radius)}
|
|
24
26
|
fill="none"
|
|
25
27
|
stroke={stroke}
|
|
26
28
|
strokeWidth={strokeWidth}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
+
import { knobArcPath, knobArcRadius } from '@tremolo-ui/dom'
|
|
4
|
+
|
|
3
5
|
import { useCheckPlacement } from '../_util/Placement'
|
|
4
6
|
|
|
5
|
-
import {
|
|
7
|
+
import { useKnobContext } from './context'
|
|
6
8
|
|
|
7
9
|
export function InactiveLine({
|
|
8
10
|
stroke = 'currentColor',
|
|
@@ -21,14 +23,14 @@ export function InactiveLine({
|
|
|
21
23
|
const r3 = useKnobContext((s) => s.r3)
|
|
22
24
|
const r4 = useKnobContext((s) => s.r4)
|
|
23
25
|
|
|
24
|
-
const radius =
|
|
26
|
+
const radius = knobArcRadius(strokeWidth)
|
|
25
27
|
|
|
26
28
|
return (
|
|
27
29
|
<>
|
|
28
30
|
{startValue > min && (
|
|
29
31
|
<path
|
|
30
32
|
className={className}
|
|
31
|
-
d={
|
|
33
|
+
d={knobArcPath(r1, r2, radius)}
|
|
32
34
|
fill="none"
|
|
33
35
|
stroke={stroke}
|
|
34
36
|
strokeWidth={strokeWidth}
|
|
@@ -38,7 +40,7 @@ export function InactiveLine({
|
|
|
38
40
|
{startValue < max && (
|
|
39
41
|
<path
|
|
40
42
|
className={className}
|
|
41
|
-
d={
|
|
43
|
+
d={knobArcPath(r3, r4, radius)}
|
|
42
44
|
fill="none"
|
|
43
45
|
stroke={stroke}
|
|
44
46
|
strokeWidth={strokeWidth}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ReactNode, SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { KNOB_VIEWBOX_SIZE } from '@tremolo-ui/dom'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { Placement } from '../_util/Placement'
|
|
6
6
|
|
|
7
7
|
export interface KnobSVGRootProps {
|
|
8
8
|
/**
|
|
@@ -19,7 +19,7 @@ export function SVGRoot({
|
|
|
19
19
|
}: KnobSVGRootProps & Omit<SVGProps<SVGSVGElement>, keyof KnobSVGRootProps>) {
|
|
20
20
|
return (
|
|
21
21
|
<svg
|
|
22
|
-
viewBox={`0 0 ${
|
|
22
|
+
viewBox={`0 0 ${KNOB_VIEWBOX_SIZE} ${KNOB_VIEWBOX_SIZE}`}
|
|
23
23
|
style={{
|
|
24
24
|
display: 'block',
|
|
25
25
|
...style,
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { SVGProps } from 'react'
|
|
2
2
|
|
|
3
|
+
import { KNOB_VIEWBOX_SIZE } from '@tremolo-ui/dom'
|
|
3
4
|
import { clamp } from '@tremolo-ui/functions'
|
|
4
5
|
|
|
5
6
|
import { useCheckPlacement } from '../_util/Placement'
|
|
6
7
|
|
|
7
|
-
import { useKnobContext
|
|
8
|
+
import { useKnobContext } from './context'
|
|
8
9
|
|
|
9
10
|
export interface KnobThumbProps {
|
|
10
11
|
/**
|
|
@@ -68,7 +69,7 @@ export function Thumb({
|
|
|
68
69
|
<line
|
|
69
70
|
className={classes?.thumbLine}
|
|
70
71
|
x1="50%"
|
|
71
|
-
y1={`${(
|
|
72
|
+
y1={`${(KNOB_VIEWBOX_SIZE - clamp(thumbSize, 0, 100)) / 2}%`}
|
|
72
73
|
x2="50%"
|
|
73
74
|
y2={`${thumbLineLength}%`}
|
|
74
75
|
stroke={thumbLine}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
export const viewBoxSize = 100
|
|
6
|
-
export const center = viewBoxSize / 2
|
|
3
|
+
import { type KnobAngles } from '@tremolo-ui/dom'
|
|
4
|
+
import { type Scale } from '@tremolo-ui/functions'
|
|
7
5
|
|
|
8
6
|
export type KnobConfig = {
|
|
9
7
|
value: number
|
|
@@ -16,76 +14,7 @@ export type KnobConfig = {
|
|
|
16
14
|
angleRange: number
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
export type KnobContextValue = KnobConfig &
|
|
20
|
-
/** normalized value */
|
|
21
|
-
p: number
|
|
22
|
-
/** ロータリー開始位置 */
|
|
23
|
-
r1: number
|
|
24
|
-
/** activeLine の開始位置 */
|
|
25
|
-
r2: number
|
|
26
|
-
/** activeLine の終了位置 */
|
|
27
|
-
r3: number
|
|
28
|
-
/** ロータリー終了位置 */
|
|
29
|
-
r4: number
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 角度から円弧上の点を求める。
|
|
34
|
-
*
|
|
35
|
-
* 半径は線の太さの分だけ内側に取る必要があるが、太さは ActiveLine / InactiveLine が
|
|
36
|
-
* それぞれ持つため、座標はストアで先に計算せず各コンポーネント側で求める。
|
|
37
|
-
*/
|
|
38
|
-
export function pointOnArc(angle: number, radius: number) {
|
|
39
|
-
return {
|
|
40
|
-
x: center + radius * Math.cos(radian(angle - 90)),
|
|
41
|
-
y: center + radius * Math.sin(radian(angle - 90)),
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** 線の太さが viewBox からはみ出さないようにした半径 */
|
|
46
|
-
export function arcRadius(strokeWidth: number | string | undefined) {
|
|
47
|
-
const width =
|
|
48
|
-
typeof strokeWidth === 'number'
|
|
49
|
-
? strokeWidth
|
|
50
|
-
: Number.parseFloat(String(strokeWidth))
|
|
51
|
-
return Number.isFinite(width) ? center - width / 2 : center
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Build an SVG path for an arc, splitting full turns into drawable segments. */
|
|
55
|
-
export function arcPath(startAngle: number, endAngle: number, radius: number) {
|
|
56
|
-
const start = pointOnArc(startAngle, radius)
|
|
57
|
-
const sweep = endAngle - startAngle
|
|
58
|
-
const segmentCount = Math.max(1, Math.ceil(Math.abs(sweep) / 180))
|
|
59
|
-
const segmentSweep = sweep / segmentCount
|
|
60
|
-
let path = `M ${start.x} ${start.y}`
|
|
61
|
-
|
|
62
|
-
for (let i = 1; i <= segmentCount; i += 1) {
|
|
63
|
-
const end = pointOnArc(startAngle + segmentSweep * i, radius)
|
|
64
|
-
path += ` A ${radius} ${radius} 0 0 ${segmentSweep >= 0 ? 1 : 0} ${end.x} ${end.y}`
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return path
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** 設定から描画に必要な角度を導出する。レンダー中に呼ぶ。 */
|
|
71
|
-
export function calcAngles({
|
|
72
|
-
value,
|
|
73
|
-
min,
|
|
74
|
-
max,
|
|
75
|
-
scale,
|
|
76
|
-
startValue,
|
|
77
|
-
angleRange,
|
|
78
|
-
}: KnobConfig) {
|
|
79
|
-
const p = scale.normalize(value, min, max)
|
|
80
|
-
const s = scale.normalize(startValue, min, max)
|
|
81
|
-
|
|
82
|
-
const r1 = -angleRange / 2
|
|
83
|
-
const r2 = r1 + Math.min(p, s) * angleRange
|
|
84
|
-
const r3 = r1 + Math.max(p, s) * angleRange
|
|
85
|
-
const r4 = angleRange / 2
|
|
86
|
-
|
|
87
|
-
return { p, r1, r2, r3, r4 }
|
|
88
|
-
}
|
|
17
|
+
export type KnobContextValue = KnobConfig & KnobAngles
|
|
89
18
|
|
|
90
19
|
const KnobContext = /* @__PURE__ */ createContext<KnobContextValue | null>(null)
|
|
91
20
|
|
|
@@ -9,34 +9,36 @@ import {
|
|
|
9
9
|
useRef,
|
|
10
10
|
} from 'react'
|
|
11
11
|
|
|
12
|
-
import type { AxisOptions, XY } from '@tremolo-ui/dom'
|
|
13
12
|
import {
|
|
14
13
|
applyDelta,
|
|
14
|
+
arrowKeyDirection,
|
|
15
|
+
type AxisOptions,
|
|
16
|
+
cssLength,
|
|
17
|
+
DEFAULT_DRAG_SENSITIVITY,
|
|
18
|
+
DEFAULT_KEYBOARD_OPTIONS,
|
|
19
|
+
DEFAULT_WHEEL_OPTIONS,
|
|
15
20
|
type InputEventOption,
|
|
21
|
+
knobAngles,
|
|
16
22
|
type ModifierValue,
|
|
17
23
|
selectModifier,
|
|
18
|
-
|
|
19
|
-
type
|
|
20
|
-
|
|
21
|
-
} from '@tremolo-ui/functions'
|
|
24
|
+
wheelDirection,
|
|
25
|
+
type XY,
|
|
26
|
+
} from '@tremolo-ui/dom'
|
|
27
|
+
import { linearScale, type Scale, type ValueRange } from '@tremolo-ui/functions'
|
|
22
28
|
|
|
23
29
|
import { useComposedRefs } from '../../compose-refs'
|
|
24
30
|
import { useCheckSteps } from '../../hooks/_internal/useCheckSteps'
|
|
25
31
|
import { useDragValue } from '../../hooks/useDragValue'
|
|
26
32
|
import { useWheel } from '../../hooks/useWheel'
|
|
27
|
-
import {
|
|
28
|
-
DEFAULT_DRAG_SENSITIVITY,
|
|
29
|
-
DEFAULT_KEYBOARD_OPTIONS,
|
|
30
|
-
DEFAULT_WHEEL_OPTIONS,
|
|
31
|
-
} from '../../input-event'
|
|
32
|
-
import { cssLength } from '../_util/css-length'
|
|
33
33
|
|
|
34
34
|
import { ActiveLine } from './ActiveLine'
|
|
35
|
-
import {
|
|
35
|
+
import { KnobProvider } from './context'
|
|
36
36
|
import { InactiveLine } from './InactiveLine'
|
|
37
37
|
import { SVGRoot } from './SVGRoot'
|
|
38
38
|
import { Thumb } from './Thumb'
|
|
39
39
|
|
|
40
|
+
import type { CSSVariables } from '../../css-variables'
|
|
41
|
+
|
|
40
42
|
const defaultExternalStyles: KnobProps['externalStyles'] = {
|
|
41
43
|
cursor: 'grabbing',
|
|
42
44
|
}
|
|
@@ -200,6 +202,8 @@ export interface KnobProps {
|
|
|
200
202
|
* </Knob.Root>
|
|
201
203
|
*/
|
|
202
204
|
children: ReactNode
|
|
205
|
+
|
|
206
|
+
style?: CSSProperties & CSSVariables<'knob-size'>
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
export interface KnobMethods {
|
|
@@ -262,12 +266,10 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
262
266
|
const handleKeyDown = useCallback(
|
|
263
267
|
(event: React.KeyboardEvent<HTMLOrSVGElement>) => {
|
|
264
268
|
if (!keyboard || !onChange || inactive) return
|
|
265
|
-
const
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
onChange(applyDelta(value, direction, keyboard, range, event))
|
|
270
|
-
}
|
|
269
|
+
const direction = arrowKeyDirection(event.key)
|
|
270
|
+
if (direction === null) return
|
|
271
|
+
event.preventDefault()
|
|
272
|
+
onChange(applyDelta(value, direction, keyboard, range, event))
|
|
271
273
|
},
|
|
272
274
|
[keyboard, onChange, inactive, value, range],
|
|
273
275
|
)
|
|
@@ -301,9 +303,13 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
301
303
|
|
|
302
304
|
const wheelRefCallback = useWheel<HTMLElement>((event) => {
|
|
303
305
|
if (!wheel || inactive) return
|
|
306
|
+
// A notch the knob does not read — a sideways scroll — is left to the
|
|
307
|
+
// page rather than swallowed.
|
|
308
|
+
const direction = wheelDirection(event)
|
|
309
|
+
if (direction === null) return
|
|
304
310
|
event.preventDefault()
|
|
305
|
-
if (!onChange
|
|
306
|
-
onChange(applyDelta(value,
|
|
311
|
+
if (!onChange) return
|
|
312
|
+
onChange(applyDelta(value, direction, wheel, range, event))
|
|
307
313
|
}, WHEEL_OPTIONS)
|
|
308
314
|
|
|
309
315
|
// Composed once, so React attaches the refs a single time instead of
|
|
@@ -316,7 +322,7 @@ export const Root = /* @__PURE__ */ forwardRef<KnobMethods, Props>(
|
|
|
316
322
|
|
|
317
323
|
const context = useMemo(() => {
|
|
318
324
|
const config = { value, min, max, step, scale, startValue, angleRange }
|
|
319
|
-
return { ...config, ...
|
|
325
|
+
return { ...config, ...knobAngles(config) }
|
|
320
326
|
}, [value, min, max, step, scale, startValue, angleRange])
|
|
321
327
|
|
|
322
328
|
useImperativeHandle(forwardedRef, () => {
|
|
@@ -1,22 +1,11 @@
|
|
|
1
|
-
import { ComponentPropsWithoutRef } from 'react'
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
StepperArrow,
|
|
5
3
|
StepperButton,
|
|
6
|
-
StepperButtonProps,
|
|
4
|
+
type StepperButtonProps,
|
|
7
5
|
} from './StepperButton'
|
|
8
6
|
|
|
9
|
-
// An interface rather than an alias; see `NumberInputIncrementStepperProps`.
|
|
10
|
-
export interface NumberInputDecrementStepperProps extends StepperButtonProps {}
|
|
11
|
-
|
|
12
7
|
/** Lowers the value by one `step`, repeating while held. */
|
|
13
|
-
export function DecrementStepper(
|
|
14
|
-
props: NumberInputDecrementStepperProps &
|
|
15
|
-
Omit<
|
|
16
|
-
ComponentPropsWithoutRef<'div'>,
|
|
17
|
-
keyof NumberInputDecrementStepperProps
|
|
18
|
-
>,
|
|
19
|
-
) {
|
|
8
|
+
export function DecrementStepper(props: StepperButtonProps) {
|
|
20
9
|
return (
|
|
21
10
|
<StepperButton
|
|
22
11
|
direction={-1}
|
|
@@ -1,23 +1,11 @@
|
|
|
1
|
-
import { ComponentPropsWithoutRef } from 'react'
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
StepperArrow,
|
|
5
3
|
StepperButton,
|
|
6
|
-
StepperButtonProps,
|
|
4
|
+
type StepperButtonProps,
|
|
7
5
|
} from './StepperButton'
|
|
8
6
|
|
|
9
|
-
// An interface rather than an alias, so that typedoc lists the props: the
|
|
10
|
-
// shared `StepperButtonProps` is not exported for it to follow.
|
|
11
|
-
export interface NumberInputIncrementStepperProps extends StepperButtonProps {}
|
|
12
|
-
|
|
13
7
|
/** Raises the value by one `step`, repeating while held. */
|
|
14
|
-
export function IncrementStepper(
|
|
15
|
-
props: NumberInputIncrementStepperProps &
|
|
16
|
-
Omit<
|
|
17
|
-
ComponentPropsWithoutRef<'div'>,
|
|
18
|
-
keyof NumberInputIncrementStepperProps
|
|
19
|
-
>,
|
|
20
|
-
) {
|
|
8
|
+
export function IncrementStepper(props: StepperButtonProps) {
|
|
21
9
|
return (
|
|
22
10
|
<StepperButton
|
|
23
11
|
direction={1}
|