@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.
Files changed (86) hide show
  1. package/dist/compose-refs.cjs +72 -0
  2. package/dist/compose-refs.cjs.map +1 -0
  3. package/dist/compose-refs.d.cts +43 -0
  4. package/dist/compose-refs.d.cts.map +1 -0
  5. package/dist/compose-refs.d.ts +43 -0
  6. package/dist/compose-refs.d.ts.map +1 -0
  7. package/dist/compose-refs.js +70 -0
  8. package/dist/compose-refs.js.map +1 -0
  9. package/dist/index.cjs +1669 -541
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +1571 -486
  12. package/dist/index.d.cts.map +1 -1
  13. package/dist/index.d.ts +1571 -486
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1640 -538
  16. package/dist/index.js.map +1 -1
  17. package/package.json +29 -27
  18. package/src/components/AnimationCanvas/index.tsx +115 -76
  19. package/src/components/DropZone/index.tsx +100 -0
  20. package/src/components/FileInput/Trigger.tsx +39 -0
  21. package/src/components/FileInput/context.tsx +24 -0
  22. package/src/components/FileInput/index.tsx +144 -0
  23. package/src/components/Knob/ActiveLine.tsx +7 -6
  24. package/src/components/Knob/InactiveLine.tsx +9 -10
  25. package/src/components/Knob/SVGRoot.tsx +5 -3
  26. package/src/components/Knob/Thumb.tsx +33 -11
  27. package/src/components/Knob/context.tsx +17 -1
  28. package/src/components/Knob/index.tsx +156 -60
  29. package/src/components/NumberInput/DecrementStepper.tsx +3 -10
  30. package/src/components/NumberInput/IncrementStepper.tsx +3 -10
  31. package/src/components/NumberInput/InputField.tsx +185 -108
  32. package/src/components/NumberInput/Stepper.tsx +120 -84
  33. package/src/components/NumberInput/StepperButton.tsx +100 -0
  34. package/src/components/NumberInput/context.tsx +34 -8
  35. package/src/components/NumberInput/index.tsx +270 -82
  36. package/src/components/Piano/index.tsx +363 -200
  37. package/src/components/PointsEditor/Background.tsx +13 -10
  38. package/src/components/PointsEditor/Container.tsx +83 -29
  39. package/src/components/PointsEditor/Point.tsx +241 -79
  40. package/src/components/PointsEditor/SelectionBox.tsx +48 -0
  41. package/src/components/PointsEditor/context.tsx +78 -10
  42. package/src/components/PointsEditor/index.tsx +440 -41
  43. package/src/components/Slider/Marks.tsx +48 -19
  44. package/src/components/Slider/MarksOption.tsx +64 -45
  45. package/src/components/Slider/Thumb.tsx +107 -55
  46. package/src/components/Slider/Track.tsx +66 -62
  47. package/src/components/Slider/context.tsx +4 -2
  48. package/src/components/Slider/index.tsx +136 -77
  49. package/src/components/Slider/type.ts +29 -9
  50. package/src/components/XYPad/Area.tsx +46 -41
  51. package/src/components/XYPad/Thumb.tsx +131 -64
  52. package/src/components/XYPad/context.tsx +4 -1
  53. package/src/components/XYPad/index.tsx +183 -53
  54. package/src/components/_util/Placement.tsx +68 -0
  55. package/src/components/_util/VisuallyHiddenRangeInput.tsx +12 -0
  56. package/src/components/_util/css-length.ts +13 -0
  57. package/src/components/_util/decimal-digits.ts +23 -0
  58. package/src/components/_util/visually-hidden.ts +25 -0
  59. package/src/{components/_util/composeRefs.tsx → compose-refs/index.tsx} +33 -5
  60. package/src/css-variables.ts +23 -0
  61. package/src/hooks/_internal/useCallbackRef.ts +3 -1
  62. package/src/hooks/_internal/useCheckSteps.ts +169 -0
  63. package/src/hooks/useAnimationFrame.ts +17 -7
  64. package/src/hooks/useDrag.ts +51 -12
  65. package/src/hooks/useDragValue.ts +71 -11
  66. package/src/hooks/useDropZone.ts +78 -0
  67. package/src/hooks/useEventListener.ts +10 -6
  68. package/src/hooks/useLongPress.ts +42 -8
  69. package/src/hooks/useMIDIAccess.ts +82 -11
  70. package/src/hooks/useMIDIInput.ts +45 -13
  71. package/src/hooks/useMIDIMessage.ts +23 -5
  72. package/src/hooks/useWheel.ts +4 -1
  73. package/src/index.ts +45 -25
  74. package/src/input-event.ts +32 -0
  75. package/dist/index.css +0 -501
  76. package/src/Globals.d.ts +0 -3
  77. package/src/components/Knob/index.css +0 -81
  78. package/src/components/NumberInput/index.css +0 -121
  79. package/src/components/NumberInput/stepperButton.tsx +0 -90
  80. package/src/components/Piano/index.css +0 -90
  81. package/src/components/PointsEditor/index.css +0 -35
  82. package/src/components/Slider/index.css +0 -125
  83. package/src/components/XYPad/index.css +0 -60
  84. package/src/components/_util/index.ts +0 -28
  85. package/src/styles/global.css +0 -19
  86. /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.5.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 -p 6006",
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": "jest",
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.2.18",
31
- "@types/react-dom": "19.2.5",
32
- "jest-environment-jsdom": "29.7.0",
33
- "jotai": "2.20.3",
34
- "react": "19.2.8",
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.5.0",
46
- "@tremolo-ui/functions": "^0.5.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
- "./styles/index.css": "./dist/index.css",
86
- "./styles/global.css": "./src/styles/global.css",
87
- "./styles/Knob.css": "./src/components/Knob/index.css",
88
- "./styles/NumberInput.css": "./src/components/NumberInput/index.css",
89
- "./styles/Piano.css": "./src/components/Piano/index.css",
90
- "./styles/PointsEditor.css": "./src/components/PointsEditor/index.css",
91
- "./styles/Slider.css": "./src/components/Slider/index.css",
92
- "./styles/XYPad.css": "./src/components/XYPad/index.css"
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
- ReactElement,
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 CommonProps {
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
- export interface AbsoluteSizingProps {
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
- export interface RelativeSizingProps {
50
- relativeSize?: boolean
51
- reduceFlickering?: boolean
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 = CommonProps &
55
- AbsoluteSizingProps &
56
- RelativeSizingProps
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 function AnimationCanvas(
62
- props: CommonProps &
63
- AbsoluteSizingProps &
64
- Omit<ComponentPropsWithoutRef<'canvas'>, keyof AnimationCanvasProps>,
65
- ): ReactElement
66
- export function AnimationCanvas(
67
- pros: CommonProps &
68
- RelativeSizingProps &
69
- Omit<ComponentPropsWithoutRef<'canvas'>, keyof AnimationCanvasProps>,
70
- ): ReactElement
71
- export function AnimationCanvas({
72
- // common
73
- draw,
74
- init,
75
- animate = true,
76
- options,
77
- // absolute
78
- width = 100,
79
- height = 100,
80
- // relative
81
- relativeSize = false,
82
- reduceFlickering = true,
83
- // canvas props
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
- /** Set while the instance below is newer than the effect that pushes into it. */
109
- const justCreated = useRef(false)
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
- relativeSize,
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 `relativeSize` decides how the instance is wired, so it is the one
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, relativeSize])
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={clsx('tremolo-animation-canvas', className)}
166
- ref={setNode}
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'