@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
package/package.json
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tremolo-ui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "React component library for audio app",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"sideEffects":
|
|
7
|
-
"*.css"
|
|
8
|
-
],
|
|
6
|
+
"sideEffects": false,
|
|
9
7
|
"main": "dist/index.cjs",
|
|
10
8
|
"module": "dist/index.js",
|
|
11
9
|
"types": "dist/index.d.cts",
|
|
12
10
|
"scripts": {
|
|
13
|
-
"sb": "storybook dev
|
|
11
|
+
"sb": "storybook dev",
|
|
14
12
|
"typecheck": "tsc",
|
|
15
|
-
"build:sb": "storybook build",
|
|
13
|
+
"build:sb": "storybook build -o storybook-static/i/storybook-react && cp .storybook/_headers storybook-static/_headers",
|
|
16
14
|
"serve": "http-server ./storybook-static",
|
|
17
15
|
"lint": "eslint --fix --ext .ts,.tsx .",
|
|
18
16
|
"format": "prettier --write .",
|
|
19
|
-
"test": "
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
20
19
|
"build:package": "tsc && tsdown",
|
|
21
20
|
"clean": "npm run clean:build && npm run clean:sb",
|
|
22
21
|
"clean:build": "rimraf ./dist",
|
|
@@ -24,31 +23,32 @@
|
|
|
24
23
|
},
|
|
25
24
|
"devDependencies": {
|
|
26
25
|
"@storybook/addon-a11y": "10.6.0",
|
|
26
|
+
"@storybook/addon-docs": "10.6.0",
|
|
27
27
|
"@storybook/addon-links": "10.6.0",
|
|
28
28
|
"@storybook/react-vite": "10.6.0",
|
|
29
|
+
"@testing-library/jest-dom": "7.0.1",
|
|
29
30
|
"@testing-library/react": "16.3.3",
|
|
30
|
-
"@types/react": "19.
|
|
31
|
-
"@types/react-dom": "19.
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"react": "19.
|
|
35
|
-
"react-dom": "19.2.8",
|
|
31
|
+
"@types/react": "19.3.0",
|
|
32
|
+
"@types/react-dom": "19.3.0",
|
|
33
|
+
"jotai": "3.0.0",
|
|
34
|
+
"react": "19.3.0",
|
|
35
|
+
"react-dom": "19.3.0",
|
|
36
36
|
"storybook": "10.6.0",
|
|
37
|
-
"tone": "15.1.22"
|
|
38
|
-
"@storybook/addon-docs": "10.6.0"
|
|
37
|
+
"tone": "15.1.22"
|
|
39
38
|
},
|
|
40
39
|
"peerDependencies": {
|
|
41
40
|
"react": "^18 ||^19",
|
|
42
41
|
"react-dom": "^18 || ^19"
|
|
43
42
|
},
|
|
44
43
|
"dependencies": {
|
|
45
|
-
"@tremolo-ui/dom": "^0.
|
|
46
|
-
"@tremolo-ui/functions": "^0.
|
|
47
|
-
"clsx": "^2.1.1"
|
|
44
|
+
"@tremolo-ui/dom": "^0.7.0",
|
|
45
|
+
"@tremolo-ui/functions": "^0.7.0"
|
|
48
46
|
},
|
|
49
47
|
"files": [
|
|
50
48
|
"dist",
|
|
51
|
-
"src"
|
|
49
|
+
"src",
|
|
50
|
+
"!src/**/*.test.{ts,tsx}",
|
|
51
|
+
"!src/**/*.stories.{ts,tsx}"
|
|
52
52
|
],
|
|
53
53
|
"publishConfig": {
|
|
54
54
|
"access": "public"
|
|
@@ -82,13 +82,15 @@
|
|
|
82
82
|
"default": "./dist/index.js"
|
|
83
83
|
}
|
|
84
84
|
},
|
|
85
|
-
"./
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
"./compose-refs": {
|
|
86
|
+
"require": {
|
|
87
|
+
"types": "./dist/compose-refs.d.cts",
|
|
88
|
+
"default": "./dist/compose-refs.cjs"
|
|
89
|
+
},
|
|
90
|
+
"import": {
|
|
91
|
+
"types": "./dist/compose-refs.d.ts",
|
|
92
|
+
"default": "./dist/compose-refs.js"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
93
95
|
}
|
|
94
96
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import clsx from 'clsx'
|
|
2
1
|
import {
|
|
3
2
|
ComponentPropsWithoutRef,
|
|
4
|
-
|
|
3
|
+
forwardRef,
|
|
5
4
|
useEffect,
|
|
6
5
|
useRef,
|
|
7
6
|
useState,
|
|
@@ -13,6 +12,8 @@ import {
|
|
|
13
12
|
type AnimationFrame,
|
|
14
13
|
} from '@tremolo-ui/dom'
|
|
15
14
|
|
|
15
|
+
import { useComposedRefs } from '../../compose-refs'
|
|
16
|
+
|
|
16
17
|
export type InitFunction = (
|
|
17
18
|
context: CanvasRenderingContext2D,
|
|
18
19
|
option: {
|
|
@@ -28,9 +29,27 @@ export type DrawFunction = (
|
|
|
28
29
|
option: AnimationFrame,
|
|
29
30
|
) => void
|
|
30
31
|
|
|
31
|
-
export interface
|
|
32
|
+
export interface AnimationCanvasCommonProps {
|
|
33
|
+
/**
|
|
34
|
+
* Draw one frame. It is given the 2D context and the frame: the size in CSS
|
|
35
|
+
* pixels, `count`, `deltaTime`, `elapsedTime` and `fps`.
|
|
36
|
+
*
|
|
37
|
+
* A new function replaces the old one in place, so writing it inline does
|
|
38
|
+
* not restart anything.
|
|
39
|
+
*/
|
|
32
40
|
draw: DrawFunction
|
|
41
|
+
/**
|
|
42
|
+
* Called once before the first frame, with the context and the size in CSS
|
|
43
|
+
* pixels. Set up what every frame shares here.
|
|
44
|
+
*/
|
|
33
45
|
init?: InitFunction
|
|
46
|
+
/**
|
|
47
|
+
* Redraw on every animation frame. Turn it off to draw only when there is a
|
|
48
|
+
* reason to: when the canvas mounts, when it is resized, and when the
|
|
49
|
+
* component re-renders.
|
|
50
|
+
*
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
34
53
|
animate?: boolean
|
|
35
54
|
/**
|
|
36
55
|
* Read once, when the 2D context is created, so changing it later has no
|
|
@@ -39,59 +58,88 @@ export interface CommonProps {
|
|
|
39
58
|
* @see https://developer.mozilla.org/docs/Web/API/HTMLCanvasElement/getContext#contextattributes
|
|
40
59
|
*/
|
|
41
60
|
options?: CanvasRenderingContext2DSettings
|
|
61
|
+
/**
|
|
62
|
+
* Carry the drawing across a resize, so that the canvas does not blank for a
|
|
63
|
+
* frame while the new size is drawn. A fixed canvas is resized too, when its
|
|
64
|
+
* `width` or `height` changes.
|
|
65
|
+
*
|
|
66
|
+
* @default true
|
|
67
|
+
*/
|
|
68
|
+
reduceFlickering?: boolean
|
|
42
69
|
}
|
|
43
70
|
|
|
44
|
-
|
|
71
|
+
/** A canvas of the size given in CSS pixels. This is the default. */
|
|
72
|
+
export interface AnimationCanvasFixedProps {
|
|
73
|
+
/** Leave it out, or pass `false`, for a canvas of the size below. */
|
|
74
|
+
resizable?: false
|
|
75
|
+
/**
|
|
76
|
+
* Width of the canvas in CSS pixels.
|
|
77
|
+
* @default 100
|
|
78
|
+
*/
|
|
45
79
|
width?: number
|
|
80
|
+
/**
|
|
81
|
+
* Height of the canvas in CSS pixels.
|
|
82
|
+
* @default 100
|
|
83
|
+
*/
|
|
46
84
|
height?: number
|
|
47
85
|
}
|
|
48
86
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
87
|
+
/**
|
|
88
|
+
* A canvas that follows the size of its parent element, so the parent needs a
|
|
89
|
+
* size of its own.
|
|
90
|
+
*/
|
|
91
|
+
export interface AnimationCanvasResizableProps {
|
|
92
|
+
/**
|
|
93
|
+
* Follow the size of the parent element instead of `width` and `height`.
|
|
94
|
+
* Switching it rebuilds the canvas.
|
|
95
|
+
*/
|
|
96
|
+
resizable: true
|
|
97
|
+
/** Not accepted: the size comes from the parent element. */
|
|
98
|
+
width?: never
|
|
99
|
+
/** Not accepted: the size comes from the parent element. */
|
|
100
|
+
height?: never
|
|
52
101
|
}
|
|
53
102
|
|
|
54
|
-
export type AnimationCanvasProps =
|
|
55
|
-
|
|
56
|
-
|
|
103
|
+
export type AnimationCanvasProps = AnimationCanvasCommonProps &
|
|
104
|
+
(AnimationCanvasFixedProps | AnimationCanvasResizableProps)
|
|
105
|
+
|
|
106
|
+
type Props = AnimationCanvasProps &
|
|
107
|
+
Omit<
|
|
108
|
+
ComponentPropsWithoutRef<'canvas'>,
|
|
109
|
+
keyof AnimationCanvasCommonProps | 'resizable' | 'width' | 'height'
|
|
110
|
+
>
|
|
57
111
|
|
|
58
112
|
/**
|
|
59
113
|
* A simple animatable canvas with requestAnimationFrame()
|
|
60
114
|
*/
|
|
61
|
-
export
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
className,
|
|
85
|
-
onContextMenu = (event) => event.preventDefault(),
|
|
86
|
-
...props
|
|
87
|
-
}: AnimationCanvasProps &
|
|
88
|
-
Omit<
|
|
89
|
-
ComponentPropsWithoutRef<'canvas'>,
|
|
90
|
-
keyof AnimationCanvasProps
|
|
91
|
-
>): ReactElement {
|
|
115
|
+
export const AnimationCanvas = /* @__PURE__ */ forwardRef<
|
|
116
|
+
HTMLCanvasElement,
|
|
117
|
+
Props
|
|
118
|
+
>(function AnimationCanvas(
|
|
119
|
+
{
|
|
120
|
+
// common
|
|
121
|
+
draw,
|
|
122
|
+
init,
|
|
123
|
+
animate = true,
|
|
124
|
+
options,
|
|
125
|
+
reduceFlickering = true,
|
|
126
|
+
// fixed
|
|
127
|
+
width = 100,
|
|
128
|
+
height = 100,
|
|
129
|
+
// resizable
|
|
130
|
+
resizable = false,
|
|
131
|
+
// canvas props
|
|
132
|
+
className,
|
|
133
|
+
onContextMenu = (event) => event.preventDefault(),
|
|
134
|
+
...props
|
|
135
|
+
},
|
|
136
|
+
forwardedRef,
|
|
137
|
+
) {
|
|
92
138
|
// See useDrag for why the node is held in state rather than a ref: an inline
|
|
93
139
|
// ref would be re-attached on every render and tear the instance down.
|
|
94
140
|
const [node, setNode] = useState<HTMLCanvasElement | null>(null)
|
|
141
|
+
// The caller's ref gets the same element.
|
|
142
|
+
const composedRef = useComposedRefs<HTMLCanvasElement>(forwardedRef, setNode)
|
|
95
143
|
|
|
96
144
|
// Read when the instance is created. The effect below keeps it current, and
|
|
97
145
|
// runs right after, so a stale handler is replaced within the same commit.
|
|
@@ -105,8 +153,26 @@ export function AnimationCanvas({
|
|
|
105
153
|
options,
|
|
106
154
|
})
|
|
107
155
|
const instanceRef = useRef<AnimationCanvasInstance | null>(null)
|
|
108
|
-
|
|
109
|
-
|
|
156
|
+
|
|
157
|
+
// Runs before the creation effect below. This keeps a rebuilt instance on
|
|
158
|
+
// the current render's size and handlers while updating an existing one in
|
|
159
|
+
// place on ordinary renders.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
latest.current = {
|
|
162
|
+
draw,
|
|
163
|
+
init,
|
|
164
|
+
animate,
|
|
165
|
+
width,
|
|
166
|
+
height,
|
|
167
|
+
reduceFlickering,
|
|
168
|
+
options,
|
|
169
|
+
}
|
|
170
|
+
instanceRef.current?.update({
|
|
171
|
+
animate,
|
|
172
|
+
size: { width, height },
|
|
173
|
+
reduceFlickering,
|
|
174
|
+
})
|
|
175
|
+
})
|
|
110
176
|
|
|
111
177
|
useEffect(() => {
|
|
112
178
|
if (!node) return
|
|
@@ -118,54 +184,27 @@ export function AnimationCanvas({
|
|
|
118
184
|
animate: current.animate,
|
|
119
185
|
size: { width: current.width, height: current.height },
|
|
120
186
|
reduceFlickering: current.reduceFlickering,
|
|
121
|
-
|
|
187
|
+
resizable,
|
|
122
188
|
contextAttributes: current.options,
|
|
123
189
|
})
|
|
124
190
|
instanceRef.current = instance
|
|
125
|
-
justCreated.current = true
|
|
126
191
|
|
|
127
192
|
return () => {
|
|
128
193
|
instanceRef.current = null
|
|
129
194
|
instance.destroy()
|
|
130
195
|
}
|
|
131
|
-
// Only `
|
|
196
|
+
// Only `resizable` decides how the instance is wired, so it is the one
|
|
132
197
|
// setting that rebuilds it. `options` is read from the ref above rather
|
|
133
198
|
// than depended on: it is almost always written inline, and depending on
|
|
134
199
|
// it would tear the canvas down on every render.
|
|
135
|
-
}, [node,
|
|
136
|
-
|
|
137
|
-
// Runs after every render: the handlers come from props and are cheap to
|
|
138
|
-
// push, and updating in place keeps the frame count and elapsed time going.
|
|
139
|
-
useEffect(() => {
|
|
140
|
-
latest.current = {
|
|
141
|
-
draw,
|
|
142
|
-
init,
|
|
143
|
-
animate,
|
|
144
|
-
width,
|
|
145
|
-
height,
|
|
146
|
-
reduceFlickering,
|
|
147
|
-
options,
|
|
148
|
-
}
|
|
149
|
-
// The instance was built from `latest` a moment ago, so there is nothing
|
|
150
|
-
// to push yet. Skipping matters with `animate` off, where `update()` draws
|
|
151
|
-
// a frame and would otherwise paint the same one twice on mount.
|
|
152
|
-
if (justCreated.current) {
|
|
153
|
-
justCreated.current = false
|
|
154
|
-
return
|
|
155
|
-
}
|
|
156
|
-
instanceRef.current?.update({
|
|
157
|
-
animate,
|
|
158
|
-
size: { width, height },
|
|
159
|
-
reduceFlickering,
|
|
160
|
-
})
|
|
161
|
-
})
|
|
200
|
+
}, [node, resizable])
|
|
162
201
|
|
|
163
202
|
return (
|
|
164
203
|
<canvas
|
|
165
|
-
className={
|
|
166
|
-
ref={
|
|
204
|
+
className={className}
|
|
205
|
+
ref={composedRef}
|
|
167
206
|
onContextMenu={onContextMenu}
|
|
168
207
|
{...props}
|
|
169
208
|
/>
|
|
170
209
|
)
|
|
171
|
-
}
|
|
210
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
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.
|
|
43
|
+
*/
|
|
44
|
+
onReject?: (files: File[], event: DragEvent) => void
|
|
45
|
+
|
|
46
|
+
/** What the zone shows. */
|
|
47
|
+
children: ReactNode
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Props = DropZoneProps &
|
|
51
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof DropZoneProps>
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* An area that takes files dropped onto it.
|
|
55
|
+
*
|
|
56
|
+
* To make an element you already have into a drop target, without wrapping it
|
|
57
|
+
* in anything, use the `useDropZone` hook this is built on.
|
|
58
|
+
*/
|
|
59
|
+
export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
60
|
+
(
|
|
61
|
+
{
|
|
62
|
+
accept,
|
|
63
|
+
multiple = false,
|
|
64
|
+
disabled = false,
|
|
65
|
+
onDrop,
|
|
66
|
+
onReject,
|
|
67
|
+
children,
|
|
68
|
+
...props
|
|
69
|
+
},
|
|
70
|
+
ref,
|
|
71
|
+
) => {
|
|
72
|
+
const { refCallback, over, invalid } = useDropZone<HTMLDivElement>({
|
|
73
|
+
accept,
|
|
74
|
+
multiple,
|
|
75
|
+
disabled,
|
|
76
|
+
onDrop,
|
|
77
|
+
onReject,
|
|
78
|
+
})
|
|
79
|
+
const composedRef = useComposedRefs<HTMLDivElement>(ref, refCallback)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
ref={composedRef}
|
|
84
|
+
data-dragover={over ? '' : undefined}
|
|
85
|
+
data-invalid={invalid ? '' : undefined}
|
|
86
|
+
data-disabled={disabled ? '' : undefined}
|
|
87
|
+
{...props}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* An area that takes files dropped onto it.
|
|
97
|
+
*/
|
|
98
|
+
export const DropZone = {
|
|
99
|
+
Root,
|
|
100
|
+
}
|
|
@@ -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,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeEvent,
|
|
3
|
+
ComponentPropsWithoutRef,
|
|
4
|
+
forwardRef,
|
|
5
|
+
ReactNode,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from 'react'
|
|
9
|
+
|
|
10
|
+
import { matchesAccept } from '@tremolo-ui/dom'
|
|
11
|
+
|
|
12
|
+
import { visuallyHiddenStyle } from '../_util/visually-hidden'
|
|
13
|
+
|
|
14
|
+
import { FileInputProvider } from './context'
|
|
15
|
+
import { Trigger } from './Trigger'
|
|
16
|
+
|
|
17
|
+
export interface FileInputProps {
|
|
18
|
+
/**
|
|
19
|
+
* Which files to take, written the way the HTML attribute is: a comma
|
|
20
|
+
* separated list of extensions (`.wav`), MIME types (`audio/wav`) and type
|
|
21
|
+
* groups (`audio/*`).
|
|
22
|
+
*
|
|
23
|
+
* **The browser treats it as a hint to the picker, not a rule.** A person
|
|
24
|
+
* can switch the picker to "All Files", so what arrives is checked again
|
|
25
|
+
* here, and anything that does not match goes to `onReject` instead.
|
|
26
|
+
*/
|
|
27
|
+
accept?: string
|
|
28
|
+
/**
|
|
29
|
+
* Let more than one file be picked at a time.
|
|
30
|
+
* @default false
|
|
31
|
+
*/
|
|
32
|
+
multiple?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Refuse to open the picker, and mark every part with `[data-disabled]`.
|
|
35
|
+
* @default false
|
|
36
|
+
*/
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Called with the files that were picked and match `accept`.
|
|
41
|
+
*
|
|
42
|
+
* It is not called at all when the picker is dismissed, or when every file
|
|
43
|
+
* picked was rejected — an empty array never arrives, so there is no need to
|
|
44
|
+
* tell "nothing was chosen" from "the choice was cleared".
|
|
45
|
+
*/
|
|
46
|
+
onChange?: (files: File[]) => void
|
|
47
|
+
/**
|
|
48
|
+
* Called with the files that were picked and do not match `accept`, so that
|
|
49
|
+
* the reason can be shown. Given alongside `onChange` when a selection held
|
|
50
|
+
* both.
|
|
51
|
+
*/
|
|
52
|
+
onReject?: (files: File[]) => void
|
|
53
|
+
|
|
54
|
+
/** Normally a `FileInput.Trigger`, and whatever shows what was picked. */
|
|
55
|
+
children: ReactNode
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Props = FileInputProps &
|
|
59
|
+
Omit<ComponentPropsWithoutRef<'div'>, keyof FileInputProps>
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hands over the files a person picked.
|
|
63
|
+
*
|
|
64
|
+
* The `<input type="file">` is here, kept out of sight but in the tab order,
|
|
65
|
+
* because it is the control: it takes the focus, opens the picker on Enter,
|
|
66
|
+
* and carries the accessible name. What is on screen is whatever the children
|
|
67
|
+
* draw.
|
|
68
|
+
*/
|
|
69
|
+
export const Root = /* @__PURE__ */ forwardRef<HTMLDivElement, Props>(
|
|
70
|
+
(
|
|
71
|
+
{
|
|
72
|
+
accept,
|
|
73
|
+
multiple = false,
|
|
74
|
+
disabled = false,
|
|
75
|
+
onChange,
|
|
76
|
+
onReject,
|
|
77
|
+
children,
|
|
78
|
+
// Pulled out of `props` so that they land on the input rather than on
|
|
79
|
+
// the wrapper: it is the control, and a description is only announced
|
|
80
|
+
// from the element that takes the focus. No declaration in
|
|
81
|
+
// `FileInputProps` is needed for this — they are already on the div
|
|
82
|
+
// attributes it is merged with, with the same types.
|
|
83
|
+
'aria-label': ariaLabel,
|
|
84
|
+
'aria-labelledby': ariaLabelledby,
|
|
85
|
+
'aria-describedby': ariaDescribedby,
|
|
86
|
+
...props
|
|
87
|
+
},
|
|
88
|
+
ref,
|
|
89
|
+
) => {
|
|
90
|
+
const inputId = useId()
|
|
91
|
+
|
|
92
|
+
const context = useMemo(() => ({ inputId, disabled }), [inputId, disabled])
|
|
93
|
+
|
|
94
|
+
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
|
95
|
+
const picked = Array.from(event.target.files ?? [])
|
|
96
|
+
|
|
97
|
+
// Picking the same file twice fires no second change event while the
|
|
98
|
+
// value is still on the input, so it is cleared as soon as it is read.
|
|
99
|
+
// Nothing is lost: the files are already in hand.
|
|
100
|
+
event.target.value = ''
|
|
101
|
+
|
|
102
|
+
const accepted: File[] = []
|
|
103
|
+
const rejected: File[] = []
|
|
104
|
+
for (const file of picked) {
|
|
105
|
+
if (matchesAccept(file, accept)) accepted.push(file)
|
|
106
|
+
else rejected.push(file)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (rejected.length > 0) onReject?.(rejected)
|
|
110
|
+
if (accepted.length > 0) onChange?.(accepted)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<FileInputProvider value={context}>
|
|
115
|
+
<div ref={ref} data-disabled={disabled ? '' : undefined} {...props}>
|
|
116
|
+
<input
|
|
117
|
+
id={inputId}
|
|
118
|
+
type="file"
|
|
119
|
+
accept={accept}
|
|
120
|
+
multiple={multiple}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
style={visuallyHiddenStyle}
|
|
123
|
+
aria-label={ariaLabel}
|
|
124
|
+
aria-labelledby={ariaLabelledby}
|
|
125
|
+
aria-describedby={ariaDescribedby}
|
|
126
|
+
onChange={handleChange}
|
|
127
|
+
/>
|
|
128
|
+
{children}
|
|
129
|
+
</div>
|
|
130
|
+
</FileInputProvider>
|
|
131
|
+
)
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Picks files, and hands them over as `File`s.
|
|
137
|
+
*/
|
|
138
|
+
export const FileInput = {
|
|
139
|
+
Root,
|
|
140
|
+
Trigger,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { useFileInputContext, type FileInputContextValue } from './context'
|
|
144
|
+
export { type FileInputTriggerProps } from './Trigger'
|