@untemps/react-vocal 2.0.0-beta.6 → 2.0.0-beta.8

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 (42) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +23 -5
  3. package/dist/index.es.js +233 -759
  4. package/dist/index.js +2 -4
  5. package/package.json +7 -6
  6. package/.github/workflows/publish.yml +0 -32
  7. package/.husky/commit-msg +0 -1
  8. package/.husky/pre-commit +0 -1
  9. package/.prettierignore +0 -3
  10. package/.prettierrc +0 -29
  11. package/CLAUDE.md +0 -59
  12. package/assets/icon-idle.png +0 -0
  13. package/assets/icon-listening.png +0 -0
  14. package/assets/microphone.png +0 -0
  15. package/assets/react-vocal.png +0 -0
  16. package/commitlint.config.js +0 -7
  17. package/dev/index.html +0 -24
  18. package/dev/package.json +0 -18
  19. package/dev/public/index.html +0 -24
  20. package/dev/src/index.jsx +0 -66
  21. package/dev/vite.config.js +0 -10
  22. package/dev/yarn.lock +0 -325
  23. package/dist/index.es.js.map +0 -1
  24. package/dist/index.js.map +0 -1
  25. package/dist/index.umd.js +0 -9
  26. package/dist/index.umd.js.map +0 -1
  27. package/src/components/Icon.jsx +0 -24
  28. package/src/components/Vocal.jsx +0 -261
  29. package/src/components/__tests__/Icon.test.jsx +0 -38
  30. package/src/components/__tests__/Vocal.test.jsx +0 -748
  31. package/src/components/__tests__/VocalWithMockedUseVocal.test.jsx +0 -38
  32. package/src/components/__tests__/__snapshots__/Icon.test.jsx.snap +0 -21
  33. package/src/components/__tests__/__snapshots__/Vocal.test.jsx.snap +0 -28
  34. package/src/hooks/__tests__/useCommands.test.js +0 -115
  35. package/src/hooks/__tests__/useTimeout.test.js +0 -69
  36. package/src/hooks/__tests__/useVocal.test.js +0 -207
  37. package/src/hooks/useCommands.js +0 -75
  38. package/src/hooks/useTimeout.js +0 -21
  39. package/src/hooks/useVocal.js +0 -56
  40. package/src/index.js +0 -7
  41. package/vite.config.js +0 -36
  42. package/vitest.setup.js +0 -83
@@ -1,261 +0,0 @@
1
- import React, { cloneElement, isValidElement, useCallback, useMemo, useRef, useState } from 'react'
2
- import { Vocal as SpeechRecognitionWrapper } from '@untemps/vocal'
3
- import { isFunction } from '@untemps/utils/function/isFunction'
4
-
5
- import useVocal from '../hooks/useVocal'
6
- import useTimeout from '../hooks/useTimeout'
7
- import useCommands from '../hooks/useCommands'
8
-
9
- import Icon from './Icon'
10
-
11
- const tryMatchCommand = (segmentData, trigger) => {
12
- for (const { alternatives } of segmentData) {
13
- for (const a of alternatives) {
14
- if (trigger(a) !== null) return
15
- }
16
- }
17
- }
18
-
19
- const Vocal = ({
20
- children,
21
- commands = null,
22
- lang = 'en-US',
23
- grammars = null,
24
- timeout = 3000,
25
- silenceTimeout = null,
26
- precision = 0.4, // Fuse.js score threshold for phrase commands only; single-word commands always use exact lookup
27
- maxAlternatives = 1,
28
- continuous = false,
29
- ariaLabel = 'start recognition',
30
- style = null,
31
- className = null,
32
- outlineStyle = '2px solid',
33
- onStart = null,
34
- onEnd = null,
35
- onSpeechStart = null,
36
- onSpeechEnd = null,
37
- onResult = null,
38
- onError = null,
39
- onNoMatch = null,
40
- __rsInstance,
41
- }) => {
42
- const buttonRef = useRef(null)
43
- const [isListening, setIsListening] = useState(false)
44
-
45
- const [, { start, stop, subscribe, unsubscribe }] = useVocal(lang, grammars, maxAlternatives, continuous, __rsInstance)
46
- const triggerCommand = useCommands(commands, precision)
47
-
48
- const propsRef = useRef({})
49
- propsRef.current = { onStart, onEnd, onSpeechStart, onSpeechEnd, onResult, onError, onNoMatch }
50
-
51
- const continuousRef = useRef(continuous)
52
- continuousRef.current = continuous
53
-
54
- // In continuous mode, transcript accumulates across segments and is only emitted via onResult on session end
55
- const accumulatedRef = useRef({ transcript: '', event: null })
56
-
57
- const triggerCommandRef = useRef(triggerCommand)
58
- triggerCommandRef.current = triggerCommand
59
-
60
- const unsubscribeAllRef = useRef(null)
61
- const onEndRef = useRef(null)
62
-
63
- const silenceTimeoutRef = useRef(silenceTimeout)
64
- silenceTimeoutRef.current = silenceTimeout
65
-
66
- // Breaks the circular dep: _onEnd → useTimeout(handler) → startTimer captures _onEnd
67
- const stableTimerCb = useCallback(() => onEndRef.current?.(), [])
68
- const [startTimer, stopTimer] = useTimeout(stableTimerCb, timeout)
69
- const [startSilenceTimer, stopSilenceTimer] = useTimeout(stableTimerCb, silenceTimeout ?? 0)
70
-
71
- const stopRecognition = useCallback(() => {
72
- try {
73
- setIsListening(false)
74
- stop()
75
- } catch (error) {
76
- propsRef.current.onError?.(error)
77
- unsubscribeAllRef.current?.()
78
- }
79
- }, [stop])
80
-
81
- const _onStart = useCallback(
82
- (e) => {
83
- startTimer()
84
- propsRef.current.onStart?.(e)
85
- },
86
- [startTimer]
87
- )
88
-
89
- const _onSpeechStart = useCallback(
90
- (e) => {
91
- stopTimer()
92
- propsRef.current.onSpeechStart?.(e)
93
- },
94
- [stopTimer]
95
- )
96
-
97
- const _onSpeechEnd = useCallback(
98
- (e) => {
99
- startTimer()
100
- propsRef.current.onSpeechEnd?.(e)
101
- },
102
- [startTimer]
103
- )
104
-
105
- const _onResult = useCallback(
106
- (event) => {
107
- const segmentData = Array.from(event?.results ?? [], (segment) => {
108
- let best = { confidence: -Infinity, transcript: '' }
109
- const alternatives = []
110
- for (let j = 0; j < segment.length; j++) {
111
- const alt = segment[j]
112
- alternatives.push(alt.transcript ?? '')
113
- if (alt.confidence === undefined || alt.confidence > best.confidence) {
114
- best = alt
115
- }
116
- }
117
- return { best: best.transcript ?? '', alternatives }
118
- })
119
- const transcript = segmentData.map((s) => s.best).join('')
120
-
121
- stopTimer()
122
- if (continuousRef.current) {
123
- // Accumulate — onResult fires once at session end, not after each segment
124
- accumulatedRef.current.transcript = transcript
125
- accumulatedRef.current.event = event
126
- if (silenceTimeoutRef.current > 0) startSilenceTimer()
127
- } else {
128
- tryMatchCommand(segmentData, triggerCommandRef.current)
129
- stopRecognition()
130
- propsRef.current.onResult?.(transcript, event)
131
- }
132
- },
133
- [stopTimer, startSilenceTimer, stopRecognition]
134
- )
135
-
136
- const _onError = useCallback(
137
- (error) => {
138
- stopRecognition()
139
- propsRef.current.onError?.(error)
140
- },
141
- [stopRecognition]
142
- )
143
-
144
- const _onNoMatch = useCallback(
145
- (e) => {
146
- stopTimer()
147
- stopRecognition()
148
- propsRef.current.onNoMatch?.(e)
149
- },
150
- [stopTimer, stopRecognition]
151
- )
152
-
153
- const _onEnd = useCallback(
154
- (e) => {
155
- stopTimer()
156
- stopSilenceTimer()
157
- try {
158
- stopRecognition()
159
- unsubscribeAllRef.current?.()
160
- if (continuousRef.current && accumulatedRef.current.transcript) {
161
- propsRef.current.onResult?.(accumulatedRef.current.transcript, accumulatedRef.current.event)
162
- accumulatedRef.current.transcript = ''
163
- accumulatedRef.current.event = null
164
- }
165
- } finally {
166
- propsRef.current.onEnd?.(e)
167
- }
168
- },
169
- [stopTimer, stopSilenceTimer, stopRecognition]
170
- )
171
-
172
- onEndRef.current = _onEnd
173
-
174
- const HANDLERS = useMemo(
175
- () => ({
176
- start: _onStart,
177
- end: _onEnd,
178
- speechstart: _onSpeechStart,
179
- speechend: _onSpeechEnd,
180
- result: _onResult,
181
- error: _onError,
182
- nomatch: _onNoMatch,
183
- }),
184
- [_onStart, _onEnd, _onSpeechStart, _onSpeechEnd, _onResult, _onError, _onNoMatch]
185
- )
186
-
187
- // Assigned inline (not in useEffect) so it's ready before any event fires
188
- unsubscribeAllRef.current = () => Object.entries(HANDLERS).forEach(([event, fn]) => unsubscribe?.(event, fn))
189
-
190
- const startRecognition = useCallback(() => {
191
- try {
192
- accumulatedRef.current.transcript = ''
193
- accumulatedRef.current.event = null
194
- stopSilenceTimer()
195
- setIsListening(true)
196
- Object.entries(HANDLERS).forEach(([event, fn]) => subscribe(event, fn))
197
- start()
198
- } catch (error) {
199
- _onError(error)
200
- }
201
- }, [HANDLERS, subscribe, start, stopSilenceTimer, _onError])
202
-
203
- const _onFocus = () => {
204
- if (!className && outlineStyle) {
205
- buttonRef.current.style.outline = outlineStyle
206
- }
207
- }
208
-
209
- const _onBlur = () => {
210
- if (!className && outlineStyle) {
211
- buttonRef.current.style.outline = 'none'
212
- }
213
- }
214
-
215
- const _renderDefault = () => (
216
- <button
217
- data-testid="__vocal-root__"
218
- ref={buttonRef}
219
- aria-label={ariaLabel}
220
- aria-pressed={isListening}
221
- style={
222
- className
223
- ? null
224
- : {
225
- width: 24,
226
- height: 24,
227
- backgroundColor: 'transparent', // `background: none` shorthand resets all sub-properties; jsdom 29 + jest-dom v6 don't reflect that correctly via getComputedStyle
228
- border: 'none',
229
- padding: 0,
230
- cursor: !continuous && isListening ? 'default' : 'pointer',
231
- ...style,
232
- }
233
- }
234
- className={className}
235
- onFocus={_onFocus}
236
- onBlur={_onBlur}
237
- onClick={isListening ? stopRecognition : startRecognition}
238
- >
239
- <Icon isActive={isListening} color="#aaa" />
240
- </button>
241
- )
242
-
243
- const _renderChildren = () => {
244
- if (SpeechRecognitionWrapper.isSupported) {
245
- if (isFunction(children)) {
246
- return children(startRecognition, stopRecognition, isListening)
247
- } else if (isValidElement(children)) {
248
- return cloneElement(children, {
249
- ...(!isListening && { onClick: startRecognition }),
250
- })
251
- } else {
252
- return _renderDefault()
253
- }
254
- }
255
- return null
256
- }
257
-
258
- return _renderChildren()
259
- }
260
-
261
- export default Vocal
@@ -1,38 +0,0 @@
1
- import React from 'react'
2
- import { render } from '@testing-library/react'
3
-
4
- import Icon from '../Icon'
5
-
6
- const defaultProps = {}
7
- const getInstance = (props = {}) => <Icon {...defaultProps} {...props} />
8
-
9
- describe('Icon', () => {
10
- it('matches snapshot', () => {
11
- const { asFragment } = render(getInstance())
12
- expect(asFragment()).toMatchSnapshot()
13
- })
14
-
15
- it('renders component', () => {
16
- const { queryByTestId } = render(getInstance())
17
- expect(queryByTestId('__icon-root__')).toBeInTheDocument()
18
- })
19
-
20
- it('renders component color', () => {
21
- const color = 'green'
22
- const { queryByTestId } = render(getInstance({ color }))
23
- expect(queryByTestId('__icon-path__')).toHaveAttribute('fill', color)
24
- })
25
-
26
- it('renders active component', () => {
27
- const isActive = true
28
- const { queryByTestId } = render(getInstance({ isActive }))
29
- expect(queryByTestId('__icon-active__')).toBeInTheDocument()
30
- })
31
-
32
- it('renders active component color', () => {
33
- const isActive = true
34
- const activeColor = 'blue'
35
- const { queryByTestId } = render(getInstance({ isActive, activeColor }))
36
- expect(queryByTestId('__icon-active__')).toHaveAttribute('fill', activeColor)
37
- })
38
- })