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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@untemps/react-vocal",
3
- "version": "2.0.0-beta.4",
3
+ "version": "2.0.0-beta.5",
4
4
  "author": "Vincent Le Badezet <v.lebadezet@untemps.net>",
5
5
  "repository": "git@github.com:untemps/react-vocal.git",
6
6
  "license": "MIT",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "devDependencies": {
29
29
  "@commitlint/cli": "^20.5.3",
30
+ "fuse.js": "^7.3.0",
30
31
  "@commitlint/config-conventional": "^20.5.3",
31
32
  "@semantic-release/changelog": "^6.0.3",
32
33
  "@semantic-release/git": "^10.0.1",
@@ -47,12 +48,17 @@
47
48
  "vitest": "^4.1.5"
48
49
  },
49
50
  "peerDependencies": {
51
+ "fuse.js": ">=6.0.0",
50
52
  "react": ">=16.13.1",
51
53
  "react-dom": ">=16.13.1"
52
54
  },
55
+ "peerDependenciesMeta": {
56
+ "fuse.js": {
57
+ "optional": true
58
+ }
59
+ },
53
60
  "dependencies": {
54
- "@untemps/vocal": "^1.3.0",
55
- "fuse.js": "^7.3.0"
61
+ "@untemps/vocal": "^1.3.0"
56
62
  },
57
63
  "release": {
58
64
  "branches": [
@@ -1,7 +1,17 @@
1
- import { renderHook } from '@testing-library/react'
1
+ import { act, renderHook } from '@testing-library/react'
2
2
 
3
3
  import useCommands from '../useCommands'
4
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
+
5
15
  describe('useCommands', () => {
6
16
  it('returns triggerCommand function', () => {
7
17
  const triggerCommand = renderHook(() => useCommands())
@@ -41,13 +51,15 @@ describe('useCommands', () => {
41
51
  ['Change la bordure en vert', 'Change la bordure en verre de rouge', null],
42
52
  ['Change la bordure en vert', 'Change la bordure en violet', null],
43
53
  ['Change la bordure en vert', 'Modifie la bordure en violet', null],
44
- ])('triggers callback mapped to approximate inputs', (command, input, expected) => {
54
+ ])('triggers callback mapped to approximate inputs', async (command, input, expected) => {
45
55
  const commands = {
46
56
  [command]: () => value,
47
57
  }
48
58
  const {
49
59
  result: { current: triggerCommand },
50
60
  } = renderHook(() => useCommands(commands))
61
+ // Flush the dynamic import microtask
62
+ await act(async () => {})
51
63
  expect(triggerCommand(input)).toBe(expected)
52
64
  })
53
65
  })
@@ -86,4 +98,18 @@ describe('useCommands', () => {
86
98
  // The engine surfaces 'vert' as a secondary alternative (score 0) — exact match
87
99
  expect(triggerCommand('vert')).toBe('green')
88
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
+ })
89
115
  })
@@ -1,5 +1,4 @@
1
- import { useMemo } from 'react'
2
- import Fuse from 'fuse.js'
1
+ import { useEffect, useMemo, useRef } from 'react'
3
2
 
4
3
  const useCommands = (commands, precision = 0.4) => {
5
4
  const normalized = useMemo(
@@ -16,11 +15,28 @@ const useCommands = (commands, precision = 0.4) => {
16
15
  // Single-word keys use exact case-insensitive lookup — simpler and no false positives.
17
16
  const hasPhraseKeys = useMemo(() => keys.some((k) => k.includes(' ')), [keys])
18
17
 
19
- // precision only applies to phrase keys — single-word keys always use exact lookup
20
- const fuse = useMemo(
21
- () => (hasPhraseKeys ? new Fuse(keys, { includeScore: true, ignoreLocation: true }) : null),
22
- [hasPhraseKeys, keys]
23
- )
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])
24
40
 
25
41
  const triggerCommand = (input) => {
26
42
  if (!keys.length) return null
@@ -35,10 +51,20 @@ const useCommands = (commands, precision = 0.4) => {
35
51
  return null
36
52
  }
37
53
 
38
- const result = fuse.search(input).filter((r) => r.score < precision)
39
- if (result?.length) {
40
- const key = result[0].item.toLowerCase()
41
- return normalized[key]?.(input)
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)
42
68
  }
43
69
  return null
44
70
  }
package/vite.config.js CHANGED
@@ -11,11 +11,12 @@ export default defineConfig({
11
11
  fileName: (format) => ({ es: 'index.es.js', umd: 'index.umd.js', cjs: 'index.js' })[format],
12
12
  },
13
13
  rollupOptions: {
14
- external: ['react', 'react-dom'],
14
+ external: ['react', 'react-dom', 'fuse.js'],
15
15
  output: {
16
16
  globals: {
17
17
  react: 'React',
18
18
  'react-dom': 'ReactDOM',
19
+ 'fuse.js': 'Fuse',
19
20
  },
20
21
  },
21
22
  },