@untemps/react-vocal 2.0.0-beta.5 → 2.0.0-beta.7

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 +9 -0
  2. package/README.md +4 -1
  3. package/dist/index.es.js +99 -78
  4. package/dist/index.js +2 -2
  5. package/dist/index.umd.js +2 -2
  6. package/package.json +7 -1
  7. package/.github/workflows/publish.yml +0 -32
  8. package/.husky/commit-msg +0 -1
  9. package/.husky/pre-commit +0 -1
  10. package/.prettierignore +0 -3
  11. package/.prettierrc +0 -29
  12. package/CLAUDE.md +0 -59
  13. package/assets/icon-idle.png +0 -0
  14. package/assets/icon-listening.png +0 -0
  15. package/assets/microphone.png +0 -0
  16. package/assets/react-vocal.png +0 -0
  17. package/commitlint.config.js +0 -7
  18. package/dev/index.html +0 -24
  19. package/dev/package.json +0 -18
  20. package/dev/public/index.html +0 -24
  21. package/dev/src/index.jsx +0 -58
  22. package/dev/vite.config.js +0 -10
  23. package/dev/yarn.lock +0 -325
  24. package/dist/index.es.js.map +0 -1
  25. package/dist/index.js.map +0 -1
  26. package/dist/index.umd.js.map +0 -1
  27. package/src/components/Icon.jsx +0 -24
  28. package/src/components/Vocal.jsx +0 -233
  29. package/src/components/__tests__/Icon.test.jsx +0 -38
  30. package/src/components/__tests__/Vocal.test.jsx +0 -611
  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 -202
  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 -77
@@ -1,21 +0,0 @@
1
- // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
-
3
- exports[`Icon > matches snapshot 1`] = `
4
- <DocumentFragment>
5
- <svg
6
- data-testid="__icon-root__"
7
- height="100%"
8
- viewBox="0 0 24 24"
9
- width="100%"
10
- xmlns="http://www.w3.org/2000/svg"
11
- >
12
- <g>
13
- <path
14
- d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"
15
- data-testid="__icon-path__"
16
- fill="black"
17
- />
18
- </g>
19
- </svg>
20
- </DocumentFragment>
21
- `;
@@ -1,28 +0,0 @@
1
- // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
-
3
- exports[`Vocal > matches snapshot 1`] = `
4
- <DocumentFragment>
5
- <button
6
- aria-label="start recognition"
7
- data-testid="__vocal-root__"
8
- role="button"
9
- style="width: 24px; height: 24px; background-color: transparent; border: medium; padding: 0px; cursor: pointer;"
10
- >
11
- <svg
12
- data-testid="__icon-root__"
13
- height="100%"
14
- viewBox="0 0 24 24"
15
- width="100%"
16
- xmlns="http://www.w3.org/2000/svg"
17
- >
18
- <g>
19
- <path
20
- d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"
21
- data-testid="__icon-path__"
22
- fill="#aaa"
23
- />
24
- </g>
25
- </svg>
26
- </button>
27
- </DocumentFragment>
28
- `;
@@ -1,115 +0,0 @@
1
- import { act, renderHook } from '@testing-library/react'
2
-
3
- import useCommands from '../useCommands'
4
-
5
- // Static import anchors fuse.js in the module graph so vi.mock intercepts the
6
- // dynamic import('fuse.js') inside the hook. Without it the dynamic import resolves
7
- // against the real module before the async mock factory completes.
8
- import 'fuse.js'
9
-
10
- vi.mock('fuse.js', async () => {
11
- const actual = await vi.importActual('fuse.js')
12
- return actual
13
- })
14
-
15
- describe('useCommands', () => {
16
- it('returns triggerCommand function', () => {
17
- const triggerCommand = renderHook(() => useCommands())
18
- expect(triggerCommand).toBeDefined()
19
- })
20
-
21
- it('triggers callback mapped to the exact input', () => {
22
- const commands = {
23
- foo: () => 'bar',
24
- }
25
- const {
26
- result: { current: triggerCommand },
27
- } = renderHook(() => useCommands(commands))
28
- expect(triggerCommand('foo')).toBe('bar')
29
- })
30
-
31
- it('passes input as callback argument', () => {
32
- const commands = {
33
- foo: (input) => input,
34
- }
35
- const {
36
- result: { current: triggerCommand },
37
- } = renderHook(() => useCommands(commands))
38
- expect(triggerCommand('foo')).toBe('foo')
39
- })
40
-
41
- describe('Approximate inputs', () => {
42
- const value = 'foo'
43
- it.each([
44
- ['Change la bordure en vert', 'Change la bordure en verre', value],
45
- ['Change la bordure en vert', 'Change la bordure en verres', value],
46
- ['Change la bordure en vert', 'Change la bordure en vers', value],
47
- ['Change la bordure en vert', 'Change la bordure en vairs', value],
48
- ['Change la bordure en vert', 'Changez la bordure en verre', value],
49
- ['Change la bordure en vert', 'Changez la bodure en verre', null],
50
- ['Change la bordure en vert', 'Change la bordure en rouge', null],
51
- ['Change la bordure en vert', 'Change la bordure en verre de rouge', null],
52
- ['Change la bordure en vert', 'Change la bordure en violet', null],
53
- ['Change la bordure en vert', 'Modifie la bordure en violet', null],
54
- ])('triggers callback mapped to approximate inputs', async (command, input, expected) => {
55
- const commands = {
56
- [command]: () => value,
57
- }
58
- const {
59
- result: { current: triggerCommand },
60
- } = renderHook(() => useCommands(commands))
61
- // Flush the dynamic import microtask
62
- await act(async () => {})
63
- expect(triggerCommand(input)).toBe(expected)
64
- })
65
- })
66
-
67
- it('returns null as no command is mapped to the input', () => {
68
- const commands = {
69
- foo: () => 'bar',
70
- }
71
- const {
72
- result: { current: triggerCommand },
73
- } = renderHook(() => useCommands(commands))
74
- expect(triggerCommand('gag')).toBeNull()
75
- })
76
-
77
- it('triggers all registered commands when multiple commands are defined', () => {
78
- const commands = {
79
- rouge: () => 'red',
80
- bleu: () => 'blue',
81
- jaune: () => 'yellow',
82
- }
83
- const {
84
- result: { current: triggerCommand },
85
- } = renderHook(() => useCommands(commands))
86
- expect(triggerCommand('rouge')).toBe('red')
87
- expect(triggerCommand('bleu')).toBe('blue')
88
- expect(triggerCommand('jaune')).toBe('yellow')
89
- })
90
-
91
- it('does not match near-homophones with strict precision — rely on maxAlternatives instead', () => {
92
- const commands = { vert: () => 'green' }
93
- const {
94
- result: { current: triggerCommand },
95
- } = renderHook(() => useCommands(commands))
96
- // 'verre' scores 0.4 against 'vert' — not strictly < STRICT_PRECISION (0.4)
97
- expect(triggerCommand('verre')).toBeNull()
98
- // The engine surfaces 'vert' as a secondary alternative (score 0) — exact match
99
- expect(triggerCommand('vert')).toBe('green')
100
- })
101
-
102
- it('falls back to contains matching when fuse.js is not available', async () => {
103
- vi.doMock('fuse.js', () => {
104
- throw new Error('fuse.js not installed')
105
- })
106
- const { default: useCommandsWithoutFuse } = await import('../useCommands')
107
- const commands = { 'change color': () => 'matched' }
108
- const {
109
- result: { current: triggerCommand },
110
- } = renderHook(() => useCommandsWithoutFuse(commands))
111
- await act(async () => {})
112
- expect(triggerCommand('change color')).toBe('matched')
113
- vi.doUnmock('fuse.js')
114
- })
115
- })
@@ -1,69 +0,0 @@
1
- import { renderHook } from '@testing-library/react'
2
-
3
- import useTimeout from '../useTimeout'
4
-
5
- const wait = (delay) => {
6
- return new Promise((resolve) => {
7
- setTimeout(resolve, delay)
8
- })
9
- }
10
-
11
- describe('useTimeout', () => {
12
- it('not triggers handler before calling start', () => {
13
- const handler = vi.fn()
14
- renderHook(() => useTimeout(handler))
15
- expect(handler).not.toHaveBeenCalled()
16
- })
17
-
18
- it('not triggers handler before timeout', async () => {
19
- const handler = vi.fn()
20
- const timeout = 500
21
- const {
22
- result: {
23
- current: [start],
24
- },
25
- } = renderHook(() => useTimeout(handler, timeout))
26
- start()
27
- await wait(timeout - 50)
28
- expect(handler).not.toHaveBeenCalled()
29
- })
30
-
31
- it('triggers handler immediately', async () => {
32
- const handler = vi.fn()
33
- const {
34
- result: {
35
- current: [start],
36
- },
37
- } = renderHook(() => useTimeout(handler))
38
- start()
39
- await wait(0)
40
- expect(handler).toHaveBeenCalled()
41
- })
42
-
43
- it('triggers handler after delay', async () => {
44
- const handler = vi.fn()
45
- const timeout = 500
46
- const {
47
- result: {
48
- current: [start],
49
- },
50
- } = renderHook(() => useTimeout(handler, timeout))
51
- start()
52
- await wait(timeout)
53
- expect(handler).toHaveBeenCalled()
54
- })
55
-
56
- it('not triggers handler if stop is called before timeout', async () => {
57
- const handler = vi.fn()
58
- const timeout = 500
59
- const {
60
- result: {
61
- current: [start, stop],
62
- },
63
- } = renderHook(() => useTimeout(handler, timeout))
64
- start()
65
- stop()
66
- await wait(timeout)
67
- expect(handler).not.toHaveBeenCalled()
68
- })
69
- })
@@ -1,202 +0,0 @@
1
- import { renderHook } from '@testing-library/react'
2
- import { Vocal as SpeechRecognitionWrapper } from '@untemps/vocal'
3
-
4
- import useVocal from '../useVocal'
5
-
6
- vi.mock('@untemps/vocal')
7
-
8
- describe('useVocal', () => {
9
- const mockStart = vi.fn()
10
- const mockStop = vi.fn()
11
- const mockAbort = vi.fn()
12
- const mockAddEventListener = vi.fn()
13
- const mockRemoveEventListener = vi.fn()
14
- const mockCleanup = vi.fn()
15
-
16
- const mockIsSupported = vi.fn()
17
- Object.defineProperty(SpeechRecognitionWrapper, 'isSupported', {
18
- get: mockIsSupported,
19
- })
20
-
21
- describe('with no SpeechRecognition support', () => {
22
- beforeAll(() => {
23
- mockIsSupported.mockReturnValue(false)
24
- })
25
-
26
- it('cannot create SpeechRecognition instance', () => {
27
- const {
28
- result: {
29
- current: [ref],
30
- },
31
- } = renderHook(() => useVocal())
32
- expect(ref.current).toBeNull()
33
- })
34
-
35
- it('not triggers start function', () => {
36
- const {
37
- result: {
38
- current: [, { start }],
39
- },
40
- } = renderHook(() => useVocal())
41
- start()
42
- expect(mockStart).not.toHaveBeenCalled()
43
- })
44
-
45
- it('not triggers stop function', () => {
46
- const {
47
- result: {
48
- current: [, { stop }],
49
- },
50
- } = renderHook(() => useVocal())
51
- stop()
52
- expect(mockStop).not.toHaveBeenCalled()
53
- })
54
-
55
- it('not triggers abort function', () => {
56
- const {
57
- result: {
58
- current: [, { abort }],
59
- },
60
- } = renderHook(() => useVocal())
61
- abort()
62
- expect(mockAbort).not.toHaveBeenCalled()
63
- })
64
-
65
- it('not triggers clean function', () => {
66
- const {
67
- result: {
68
- current: [, { clean }],
69
- },
70
- } = renderHook(() => useVocal())
71
- clean()
72
- expect(mockCleanup).not.toHaveBeenCalled()
73
- })
74
-
75
- it('not triggers subscribe function', () => {
76
- const {
77
- result: {
78
- current: [, { subscribe }],
79
- },
80
- } = renderHook(() => useVocal())
81
- subscribe('foo', vi.fn())
82
- expect(mockAddEventListener).not.toHaveBeenCalled()
83
- })
84
-
85
- it('not triggers unsubscribe function', () => {
86
- const {
87
- result: {
88
- current: [, { unsubscribe }],
89
- },
90
- } = renderHook(() => useVocal())
91
- unsubscribe('foo', vi.fn())
92
- expect(mockRemoveEventListener).not.toHaveBeenCalled()
93
- })
94
- })
95
-
96
- describe('with SpeechRecognition support', () => {
97
- beforeAll(() => {
98
- mockIsSupported.mockReturnValue(true)
99
- })
100
-
101
- beforeEach(() => {
102
- SpeechRecognitionWrapper.mockImplementation(function () {
103
- return {
104
- start: mockStart,
105
- stop: mockStop,
106
- abort: mockAbort,
107
- addEventListener: mockAddEventListener,
108
- removeEventListener: mockRemoveEventListener,
109
- cleanup: mockCleanup,
110
- }
111
- })
112
- })
113
-
114
- afterEach(() => {
115
- SpeechRecognitionWrapper.mockReset()
116
- })
117
-
118
- it('creates SpeechRecognition instance', () => {
119
- const {
120
- result: {
121
- current: [ref],
122
- },
123
- } = renderHook(() => useVocal())
124
- expect(ref.current).toBeDefined()
125
- })
126
-
127
- it('passes maxAlternatives to SpeechRecognitionWrapper constructor', () => {
128
- renderHook(() => useVocal('en-US', null, 5))
129
- expect(SpeechRecognitionWrapper).toHaveBeenCalledWith({ lang: 'en-US', grammars: null, maxAlternatives: 5 })
130
- })
131
-
132
- it('uses custom SpeechRecognition instance', () => {
133
- const foo = new SpeechRecognitionWrapper()
134
- const {
135
- result: {
136
- current: [ref],
137
- },
138
- } = renderHook(() => useVocal(null, null, 1, foo))
139
- expect(ref.current).toBe(foo)
140
- })
141
-
142
- it('triggers start function', () => {
143
- const {
144
- result: {
145
- current: [, { start }],
146
- },
147
- } = renderHook(() => useVocal())
148
- start()
149
- expect(mockStart).toHaveBeenCalled()
150
- })
151
-
152
- it('triggers stop function', () => {
153
- const {
154
- result: {
155
- current: [, { stop }],
156
- },
157
- } = renderHook(() => useVocal())
158
- stop()
159
- expect(mockStop).toHaveBeenCalled()
160
- })
161
-
162
- it('triggers abort function', () => {
163
- const {
164
- result: {
165
- current: [, { abort }],
166
- },
167
- } = renderHook(() => useVocal())
168
- abort()
169
- expect(mockAbort).toHaveBeenCalled()
170
- })
171
-
172
- it('triggers clean function', () => {
173
- const {
174
- result: {
175
- current: [, { clean }],
176
- },
177
- } = renderHook(() => useVocal())
178
- clean()
179
- expect(mockCleanup).toHaveBeenCalled()
180
- })
181
-
182
- it('triggers subscribe function', () => {
183
- const {
184
- result: {
185
- current: [, { subscribe }],
186
- },
187
- } = renderHook(() => useVocal())
188
- subscribe('foo', vi.fn())
189
- expect(mockAddEventListener).toHaveBeenCalled()
190
- })
191
-
192
- it('triggers unsubscribe function', () => {
193
- const {
194
- result: {
195
- current: [, { unsubscribe }],
196
- },
197
- } = renderHook(() => useVocal())
198
- unsubscribe('foo', vi.fn())
199
- expect(mockRemoveEventListener).toHaveBeenCalled()
200
- })
201
- })
202
- })
@@ -1,75 +0,0 @@
1
- import { useEffect, useMemo, useRef } from 'react'
2
-
3
- const useCommands = (commands, precision = 0.4) => {
4
- const normalized = useMemo(
5
- () =>
6
- !!commands
7
- ? Object.entries(commands).reduce((acc, [key, value]) => ({ ...acc, [key.toLowerCase()]: value }), {})
8
- : {},
9
- [commands]
10
- )
11
-
12
- const keys = useMemo(() => Object.keys(normalized), [normalized])
13
-
14
- // Fuzzy matching is only needed for phrase command keys.
15
- // Single-word keys use exact case-insensitive lookup — simpler and no false positives.
16
- const hasPhraseKeys = useMemo(() => keys.some((k) => k.includes(' ')), [keys])
17
-
18
- // Lazy-loaded so consumers using only single-word commands incur no bundle cost.
19
- const fuseRef = useRef(null)
20
-
21
- useEffect(() => {
22
- if (!hasPhraseKeys) {
23
- fuseRef.current = null
24
- return
25
- }
26
- import('fuse.js')
27
- .then((module) => {
28
- const Fuse = module.default ?? module
29
- fuseRef.current = new Fuse(keys, { includeScore: true, ignoreLocation: true })
30
- })
31
- .catch(() => {
32
- if (process.env.NODE_ENV !== 'production') {
33
- console.warn(
34
- '[react-vocal] fuse.js is not installed. Phrase command keys will fall back to exact matching. ' +
35
- 'Install fuse.js to enable fuzzy matching: npm install fuse.js'
36
- )
37
- }
38
- })
39
- }, [hasPhraseKeys, keys])
40
-
41
- const triggerCommand = (input) => {
42
- if (!keys.length) return null
43
-
44
- if (!hasPhraseKeys) {
45
- const words = input.trim().split(/\s+/)
46
- const targets = words.length > 1 ? words : [input.trim()]
47
- for (const w of targets) {
48
- const key = w.toLowerCase()
49
- if (key in normalized) return normalized[key]?.(w)
50
- }
51
- return null
52
- }
53
-
54
- const fuse = fuseRef.current
55
- if (fuse) {
56
- const result = fuse.search(input).filter((r) => r.score < precision)
57
- if (result?.length) {
58
- const key = result[0].item.toLowerCase()
59
- return normalized[key]?.(input)
60
- }
61
- } else {
62
- // `k.includes(lInput)` can produce false positives when input is short
63
- // (e.g. "rouge" matches "change en rouge"). Accepted tradeoff: this branch
64
- // only runs when fuse.js is absent, so degraded precision is expected.
65
- const lInput = input.toLowerCase()
66
- const match = keys.find((k) => lInput.includes(k) || k.includes(lInput))
67
- if (match) return normalized[match]?.(input)
68
- }
69
- return null
70
- }
71
-
72
- return triggerCommand
73
- }
74
-
75
- export default useCommands
@@ -1,21 +0,0 @@
1
- import { useCallback, useEffect, useRef } from 'react'
2
-
3
- const useTimeout = (handler, timeout = 0) => {
4
- const ref = useRef(-1)
5
-
6
- const stop = useCallback(() => {
7
- clearTimeout(ref.current)
8
- ref.current = -1
9
- }, [])
10
-
11
- const start = useCallback(() => {
12
- stop()
13
- ref.current = setTimeout(handler, timeout)
14
- }, [handler, timeout, stop])
15
-
16
- useEffect(() => stop, [stop])
17
-
18
- return [start, stop]
19
- }
20
-
21
- export default useTimeout
@@ -1,56 +0,0 @@
1
- import { useCallback, useEffect, useRef } from 'react'
2
- import { Vocal as SpeechRecognitionWrapper } from '@untemps/vocal'
3
-
4
- const useVocal = (lang = 'en-US', grammars = null, maxAlternatives = 1, __rsInstance = null) => {
5
- const ref = useRef(null)
6
-
7
- useEffect(() => {
8
- if (SpeechRecognitionWrapper.isSupported) {
9
- ref.current = __rsInstance || new SpeechRecognitionWrapper({ lang, grammars, maxAlternatives })
10
- return () => {
11
- ref.current.abort()
12
- ref.current.cleanup()
13
- }
14
- }
15
- }, [lang, grammars, maxAlternatives, __rsInstance])
16
-
17
- const start = useCallback(() => {
18
- if (ref.current) {
19
- ref.current.start()
20
- }
21
- }, [])
22
-
23
- const stop = useCallback(() => {
24
- if (ref.current) {
25
- ref.current.stop()
26
- }
27
- }, [])
28
-
29
- const abort = useCallback(() => {
30
- if (ref.current) {
31
- ref.current.abort()
32
- }
33
- }, [])
34
-
35
- const subscribe = useCallback((eventType, handler) => {
36
- if (ref.current) {
37
- ref.current.addEventListener(eventType, handler)
38
- }
39
- }, [])
40
-
41
- const unsubscribe = useCallback((eventType, handler) => {
42
- if (ref.current) {
43
- ref.current.removeEventListener(eventType, handler)
44
- }
45
- }, [])
46
-
47
- const clean = useCallback(() => {
48
- if (ref.current) {
49
- ref.current.cleanup()
50
- }
51
- }, [])
52
-
53
- return [ref, { start, stop, abort, subscribe, unsubscribe, clean }]
54
- }
55
-
56
- export default useVocal
package/src/index.js DELETED
@@ -1,7 +0,0 @@
1
- import Vocal from './components/Vocal'
2
- import { Vocal as SpeechRecognitionWrapper } from '@untemps/vocal'
3
-
4
- export { default as useVocal } from './hooks/useVocal'
5
- export const isSupported = SpeechRecognitionWrapper.isSupported
6
-
7
- export default Vocal
package/vite.config.js DELETED
@@ -1,36 +0,0 @@
1
- import { defineConfig } from 'vite'
2
- import react from '@vitejs/plugin-react'
3
-
4
- export default defineConfig({
5
- plugins: [react()],
6
- build: {
7
- lib: {
8
- entry: 'src/index.js',
9
- name: 'ReactVocal',
10
- formats: ['es', 'cjs', 'umd'],
11
- fileName: (format) => ({ es: 'index.es.js', umd: 'index.umd.js', cjs: 'index.js' })[format],
12
- },
13
- rollupOptions: {
14
- external: ['react', 'react-dom', 'fuse.js'],
15
- output: {
16
- globals: {
17
- react: 'React',
18
- 'react-dom': 'ReactDOM',
19
- 'fuse.js': 'Fuse',
20
- },
21
- },
22
- },
23
- sourcemap: true,
24
- },
25
- test: {
26
- globals: true,
27
- environment: 'jsdom',
28
- setupFiles: ['./vitest.setup.js'],
29
- restoreMocks: true,
30
- coverage: {
31
- provider: 'v8',
32
- reporter: ['text', 'lcov'],
33
- reportsDirectory: './coverage',
34
- },
35
- },
36
- })