@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.
Files changed (57) hide show
  1. package/dist/index.cjs +522 -997
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +601 -329
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.ts +601 -329
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +487 -953
  8. package/dist/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/src/components/DropZone/index.tsx +101 -0
  11. package/src/components/FileInput/Trigger.tsx +39 -0
  12. package/src/components/FileInput/context.tsx +24 -0
  13. package/src/components/FileInput/index.tsx +139 -0
  14. package/src/components/Knob/ActiveLine.tsx +5 -3
  15. package/src/components/Knob/InactiveLine.tsx +6 -4
  16. package/src/components/Knob/SVGRoot.tsx +3 -3
  17. package/src/components/Knob/Thumb.tsx +3 -2
  18. package/src/components/Knob/context.tsx +3 -74
  19. package/src/components/Knob/index.tsx +27 -21
  20. package/src/components/NumberInput/DecrementStepper.tsx +2 -13
  21. package/src/components/NumberInput/IncrementStepper.tsx +2 -14
  22. package/src/components/NumberInput/InputField.tsx +17 -38
  23. package/src/components/NumberInput/Stepper.tsx +44 -107
  24. package/src/components/NumberInput/StepperButton.tsx +16 -12
  25. package/src/components/NumberInput/context.tsx +8 -8
  26. package/src/components/NumberInput/index.tsx +41 -81
  27. package/src/components/Piano/index.tsx +39 -149
  28. package/src/components/PointsEditor/Background.tsx +2 -8
  29. package/src/components/PointsEditor/Container.tsx +17 -34
  30. package/src/components/PointsEditor/Point.tsx +41 -86
  31. package/src/components/PointsEditor/SelectionBox.tsx +2 -8
  32. package/src/components/PointsEditor/context.tsx +10 -61
  33. package/src/components/PointsEditor/index.tsx +31 -315
  34. package/src/components/Slider/Marks.tsx +8 -13
  35. package/src/components/Slider/MarksOption.tsx +11 -17
  36. package/src/components/Slider/Thumb.tsx +9 -15
  37. package/src/components/Slider/Track.tsx +7 -13
  38. package/src/components/Slider/index.tsx +33 -39
  39. package/src/components/XYPad/Area.tsx +26 -53
  40. package/src/components/XYPad/Thumb.tsx +8 -12
  41. package/src/components/XYPad/context.tsx +1 -3
  42. package/src/components/XYPad/index.tsx +35 -50
  43. package/src/components/_util/VisuallyHiddenRangeInput.tsx +3 -15
  44. package/src/css-variables.ts +23 -0
  45. package/src/hooks/_internal/useCheckSteps.ts +9 -147
  46. package/src/hooks/useDrag.ts +13 -4
  47. package/src/hooks/useDragValue.ts +17 -2
  48. package/src/hooks/useDropZone.ts +78 -0
  49. package/src/hooks/useLongPress.ts +35 -51
  50. package/src/hooks/useMIDIAccess.ts +0 -8
  51. package/src/hooks/useMIDIInput.ts +0 -3
  52. package/src/hooks/useWheel.ts +4 -1
  53. package/src/index.ts +14 -36
  54. package/src/components/Piano/keyboard-shortcuts.ts +0 -61
  55. package/src/components/Slider/type.ts +0 -44
  56. package/src/components/_util/css-length.ts +0 -13
  57. 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.6.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": "2.20.3",
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.6.0",
45
- "@tremolo-ui/functions": "^0.6.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 { arcPath, arcRadius, useKnobContext } from './context'
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 = arcRadius(strokeWidth)
20
+ const radius = knobArcRadius(strokeWidth)
19
21
 
20
22
  return (
21
23
  <path
22
24
  className={className}
23
- d={arcPath(r2, r3, radius)}
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 { arcPath, arcRadius, useKnobContext } from './context'
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 = arcRadius(strokeWidth)
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={arcPath(r1, r2, radius)}
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={arcPath(r3, r4, radius)}
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 { Placement } from '../_util/Placement'
3
+ import { KNOB_VIEWBOX_SIZE } from '@tremolo-ui/dom'
4
4
 
5
- import { viewBoxSize } from './context'
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 ${viewBoxSize} ${viewBoxSize}`}
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, viewBoxSize } from './context'
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={`${(viewBoxSize - clamp(thumbSize, 0, 100)) / 2}%`}
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 { radian, type Scale } from '@tremolo-ui/functions'
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
- linearScale,
19
- type Scale,
20
- type ValueRange,
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 { calcAngles, KnobProvider } from './context'
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 key = event.key
266
- if (['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'].includes(key)) {
267
- event.preventDefault()
268
- const direction = key === 'ArrowRight' || key === 'ArrowUp' ? 1 : -1
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 || event.deltaY === 0) return
306
- onChange(applyDelta(value, -Math.sign(event.deltaY), wheel, range, event))
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, ...calcAngles(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}