From 0e3b1244c844d8da99bb533c586e585825286790 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 4 Jun 2019 10:31:22 +0200 Subject: [PATCH] feat(voiceSearch): add voice search widget (#2316) * feat(voiceSearch): WIP add VoiceSearch component * feat(voiceSearch): WIP add voice search helper * feat(voiceSearch): WIP adding connector * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee * Apply suggestions from code review Co-Authored-By: eunjae-lee * feat(voiceSearch): rename variables * feat(voiceSearch): rename helper to voiceSearch * type(voiceSearch): fix lint error * test(voiceSearch): add tests for component * chore(voiceSearch): fix lint error * chore(voiceSearch): disable tslint for unused parameter * test(voiceSearch): add stories * fix(voiceSearch): fix wrong props * chore(voiceSearch): fix lint error * test(voiceSearch): add story for custom button * chore(voiceSearch): change to curly brace * Apply suggestions from code review Co-Authored-By: eunjae-lee * chore(voiceSearch): remove comments since the config is now global * chore(voiceSearch): add default value to searchAsYouSpeak and put typing for setSearchAsYouSpeak * chore(voiceSearch): clean up svg * fix(voiceSearch): fix class name for status * chore(voiceSearch): remove unnecessary comment * test(voiceSearch): remove unused test case * test(voiceSearch): change way to match snapshot * chore(voiceSearch): remove 'else' * chore(voiceSearch): remove unnecessary $ * test(voiceSearch): add stories * fix(voiceSearch): provide one voiceSearchHelper instancer per component * refactor(voiceSearch): move voiceSearchHelper-related logic from the connector to the component * test(voiceSearch): fix tests for the component * test(voiceSearch): add tests for VoiceSearchHelper (copied from IS.js) * chore(voiceSearch): increase limit of bundlesize * test(voiceSearch): improve tests * chore(voiceSearch): increase bundlesize limit * chore(voiceSearch): increase limit of bundlesize * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee * chore(voiceSearch): update voiceSearchHelper from IS.js * chore(voiceSearch): change code using Fragment * type(voiceSearch): Update packages/react-instantsearch-core/src/types/translatable.ts Co-Authored-By: Samuel Vaillant * chore(voiceSearch): use defaultProps * fix(types): improve types for voiceSearch * test(voiceSearch): improve test * fix(voiceSearch): rename buttonComponent to buttonTextComponent * chore(voiceSearch): change the path to import directly from source * type(voiceSearch): add accessibility modifier to defaultProps * test(voiceSearch): extract styles from stories to util.css * test(voiceSearch): clean up mocks after each test * feat(voiceSearch): add dispose to voiceSearchHelper * chore(voiceSearch): rename voiceSearchHelper to createVoiceSearchHelper * feat(voiceSearch): dispose voiceSearchHelper on unmount * chore(voiceSearch): extract svg element to a component to remove duplicates * type(voiceSearch): add accessibility modifier * chore: update bundlesize * chore: adjust bundlesize * test(voiceSearch): fix wrong cleanup * fix(voiceSearch): use defaultProps for searchAsYouSpeak * fix(types): introduce Status, ErrorCode types * fix(voiceSearch): initialize voiceSearchHelper at componentDidMount * fix(voiceSearch): stopping voice recognition also removes event listeners * chore(voiceSearch): rename index.js to index.ts and modify rollup config * chore(voiceSearch): fix wrong prop name on story * feat(voiceSearch): export createVoiceSearchHelper for users to create custom component --- package.json | 4 +- .../react-instantsearch-core/rollup.config.js | 2 +- .../src/{index.js => index.ts} | 3 + .../src/types/index.ts | 1 + .../src/types/translatable.ts | 1 + .../src/components/VoiceSearch.tsx | 157 ++++++++++++++ .../src/components/__tests__/VoiceSearch.tsx | 116 +++++++++++ .../__snapshots__/VoiceSearch.tsx.snap | 111 ++++++++++ packages/react-instantsearch-dom/src/index.js | 4 + .../lib/voiceSearchHelper/__tests__/index.ts | 196 ++++++++++++++++++ .../src/lib/voiceSearchHelper/index.ts | 162 +++++++++++++++ .../src/widgets/VoiceSearch.ts | 4 + stories/VoiceSearch.stories.tsx | 136 ++++++++++++ storybook/public/util.css | 31 +++ 14 files changed, 925 insertions(+), 3 deletions(-) rename packages/react-instantsearch-core/src/{index.js => index.ts} (98%) create mode 100644 packages/react-instantsearch-core/src/types/index.ts create mode 100644 packages/react-instantsearch-core/src/types/translatable.ts create mode 100644 packages/react-instantsearch-dom/src/components/VoiceSearch.tsx create mode 100644 packages/react-instantsearch-dom/src/components/__tests__/VoiceSearch.tsx create mode 100644 packages/react-instantsearch-dom/src/components/__tests__/__snapshots__/VoiceSearch.tsx.snap create mode 100644 packages/react-instantsearch-dom/src/lib/voiceSearchHelper/__tests__/index.ts create mode 100644 packages/react-instantsearch-dom/src/lib/voiceSearchHelper/index.ts create mode 100644 packages/react-instantsearch-dom/src/widgets/VoiceSearch.ts create mode 100644 stories/VoiceSearch.stories.tsx diff --git a/package.json b/package.json index 6b888d9659..29afaf884f 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/Dom.min.js", - "maxSize": "62.00 kB" + "maxSize": "64.20 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", @@ -142,7 +142,7 @@ }, { "path": "packages/react-instantsearch-dom/dist/umd/ReactInstantSearchDOM.min.js", - "maxSize": "63.00 kB" + "maxSize": "64.00 kB" }, { "path": "packages/react-instantsearch-dom-maps/dist/umd/ReactInstantSearchDOMMaps.min.js", diff --git a/packages/react-instantsearch-core/rollup.config.js b/packages/react-instantsearch-core/rollup.config.js index 83c84d0728..50c5a7f2d0 100644 --- a/packages/react-instantsearch-core/rollup.config.js +++ b/packages/react-instantsearch-core/rollup.config.js @@ -38,7 +38,7 @@ const plugins = [ ]; const createConfiguration = ({ name, minify = false } = {}) => ({ - input: 'src/index.js', + input: 'src/index.ts', external: ['react'], output: { file: `dist/umd/ReactInstantSearch${name}${minify ? '.min' : ''}.js`, diff --git a/packages/react-instantsearch-core/src/index.js b/packages/react-instantsearch-core/src/index.ts similarity index 98% rename from packages/react-instantsearch-core/src/index.js rename to packages/react-instantsearch-core/src/index.ts index af41f40014..8cbadcbbb3 100644 --- a/packages/react-instantsearch-core/src/index.js +++ b/packages/react-instantsearch-core/src/index.ts @@ -51,3 +51,6 @@ export { default as connectToggleRefinement, } from './connectors/connectToggleRefinement'; export { default as connectHitInsights } from './connectors/connectHitInsights'; + +// Types +export * from './types'; diff --git a/packages/react-instantsearch-core/src/types/index.ts b/packages/react-instantsearch-core/src/types/index.ts new file mode 100644 index 0000000000..0274a75363 --- /dev/null +++ b/packages/react-instantsearch-core/src/types/index.ts @@ -0,0 +1 @@ +export * from './translatable'; diff --git a/packages/react-instantsearch-core/src/types/translatable.ts b/packages/react-instantsearch-core/src/types/translatable.ts new file mode 100644 index 0000000000..03f8f7c34b --- /dev/null +++ b/packages/react-instantsearch-core/src/types/translatable.ts @@ -0,0 +1 @@ +export type Translate = (key: string, ...params: any[]) => string; diff --git a/packages/react-instantsearch-dom/src/components/VoiceSearch.tsx b/packages/react-instantsearch-dom/src/components/VoiceSearch.tsx new file mode 100644 index 0000000000..1bcf82ee3c --- /dev/null +++ b/packages/react-instantsearch-dom/src/components/VoiceSearch.tsx @@ -0,0 +1,157 @@ +import React, { Component } from 'react'; +import { translatable, Translate } from 'react-instantsearch-core'; +import { createClassNames } from '../core/utils'; +import createVoiceSearchHelper, { + VoiceSearchHelper, + VoiceListeningState, + Status, + ErrorCode, +} from '../lib/voiceSearchHelper'; +const cx = createClassNames('VoiceSearch'); + +type InnerComponentProps = { + status: Status; + errorCode?: ErrorCode; + isListening: boolean; + transcript: string; + isSpeechFinal: boolean; + isBrowserSupported: boolean; +}; + +type VoiceSearchProps = { + searchAsYouSpeak: boolean; + refine: (query: string) => void; + translate: Translate; + buttonTextComponent: React.FC; + statusComponent: React.FC; +}; + +const ButtonSvg = ({ children }) => ( + + {children} + +); + +const DefaultButtonText: React.FC = ({ + status, + errorCode, + isListening, +}) => { + return status === 'error' && errorCode === 'not-allowed' ? ( + + + + + + + + ) : ( + + + + + + + ); +}; + +const DefaultStatus: React.FC = ({ transcript }) => ( +

{transcript}

+); + +class VoiceSearch extends Component { + protected static defaultProps = { + searchAsYouSpeak: false, + buttonTextComponent: DefaultButtonText, + statusComponent: DefaultStatus, + }; + private voiceSearchHelper?: VoiceSearchHelper; + + public componentDidMount() { + const { searchAsYouSpeak, refine } = this.props; + this.voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak, + onQueryChange: query => refine(query), + onStateChange: () => { + this.setState(this.voiceSearchHelper!.getState()); + }, + }); + this.setState(this.voiceSearchHelper.getState()); + } + + public render() { + if (!this.voiceSearchHelper) { + return null; + } + + const { status, transcript, isSpeechFinal, errorCode } = this.state; + const { isListening, isBrowserSupported } = this.voiceSearchHelper; + const { + translate, + buttonTextComponent: ButtonTextComponent, + statusComponent: StatusComponent, + } = this.props; + const innerProps: InnerComponentProps = { + status, + errorCode, + isListening: isListening(), + transcript, + isSpeechFinal, + isBrowserSupported: isBrowserSupported(), + }; + + return ( +
+ +
+ +
+
+ ); + } + + public componentWillUnmount() { + if (this.voiceSearchHelper) { + this.voiceSearchHelper.dispose(); + } + } + + private onClick = (event: React.MouseEvent) => { + if (!this.voiceSearchHelper) { + return; + } + event.currentTarget.blur(); + const { toggleListening } = this.voiceSearchHelper; + toggleListening(); + }; +} + +export default translatable({ + buttonTitle: 'Search by voice', + disabledButtonTitle: 'Search by voice (not supported on this browser)', +})(VoiceSearch); diff --git a/packages/react-instantsearch-dom/src/components/__tests__/VoiceSearch.tsx b/packages/react-instantsearch-dom/src/components/__tests__/VoiceSearch.tsx new file mode 100644 index 0000000000..7b6b138f93 --- /dev/null +++ b/packages/react-instantsearch-dom/src/components/__tests__/VoiceSearch.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import Enzyme, { mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import VoiceSearch from '../VoiceSearch'; + +const mockGetState = jest.fn().mockImplementation(() => ({})); +const mockIsBrowserSupported = jest.fn().mockImplementation(() => true); +const mockIsListening = jest.fn(); +const mockToggleListening = jest.fn(); +const mockDispose = jest.fn(); + +jest.mock('../../lib/voiceSearchHelper', () => { + return () => { + return { + getState: mockGetState, + isBrowserSupported: mockIsBrowserSupported, + isListening: mockIsListening, + toggleListening: mockToggleListening, + dispose: mockDispose, + }; + }; +}); + +Enzyme.configure({ adapter: new Adapter() }); + +describe('VoiceSearch', () => { + afterEach(() => { + mockGetState.mockImplementation(() => ({})); + mockIsBrowserSupported.mockImplementation(() => true); + mockIsListening.mockClear(); + mockToggleListening.mockClear(); + }); + + describe('button', () => { + it('calls toggleListening when button is clicked', () => { + const wrapper = mount(); + wrapper.find('button').simulate('click'); + expect(mockToggleListening).toHaveBeenCalledTimes(1); + }); + }); + + describe('Rendering', () => { + it('with default props', () => { + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); + + it('with custom component for button with isListening: false', () => { + const customButtonText = ({ isListening }) => + isListening ? 'Stop' : 'Start'; + + const wrapper = mount( + + ); + expect(wrapper.find('button').text()).toBe('Start'); + }); + + it('with custom component for button with isListening: true', () => { + const customButtonText = ({ isListening }) => + isListening ? 'Stop' : 'Start'; + mockIsListening.mockImplementation(() => true); + + const wrapper = mount( + + ); + expect(wrapper.find('button').text()).toBe('Stop'); + }); + + it('renders a disabled button when the browser is not supported', () => { + mockIsBrowserSupported.mockImplementation(() => false); + const wrapper = mount(); + expect(wrapper.find('button').prop('title')).toBe( + 'Search by voice (not supported on this browser)' + ); + expect(wrapper.find('button').prop('disabled')).toBe(true); + }); + + it('with custom template for status', () => { + const customStatus = ({ + status, + errorCode, + isListening, + transcript, + isSpeechFinal, + isBrowserSupported, + }) => ( +
+

status: {status}

+

errorCode: {errorCode}

+

isListening: {isListening ? 'true' : 'false'}

+

transcript: {transcript}

+

isSpeechFinal: {isSpeechFinal ? 'true' : 'false'}

+

isBrowserSupported: {isBrowserSupported ? 'true' : 'false'}

+
+ ); + + mockIsListening.mockImplementation(() => true); + mockGetState.mockImplementation(() => ({ + status: 'recognizing', + transcript: 'Hello', + isSpeechFinal: false, + errorCode: undefined, + })); + + const wrapper = mount(); + expect(wrapper.find('.ais-VoiceSearch-status')).toMatchSnapshot(); + }); + + it('calls voiceSearchHelper.dispose() on unmount', () => { + const wrapper = mount(); + wrapper.find('button').simulate('click'); + wrapper.unmount(); + expect(mockDispose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/react-instantsearch-dom/src/components/__tests__/__snapshots__/VoiceSearch.tsx.snap b/packages/react-instantsearch-dom/src/components/__tests__/__snapshots__/VoiceSearch.tsx.snap new file mode 100644 index 0000000000..6d9e612fa0 --- /dev/null +++ b/packages/react-instantsearch-dom/src/components/__tests__/__snapshots__/VoiceSearch.tsx.snap @@ -0,0 +1,111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VoiceSearch Rendering with custom template for status 1`] = ` +
+ +
+

+ status: + recognizing +

+

+ errorCode: +

+

+ isListening: + true +

+

+ transcript: + Hello +

+

+ isSpeechFinal: + false +

+

+ isBrowserSupported: + true +

+
+
+
+`; + +exports[`VoiceSearch Rendering with default props 1`] = ` + + +
+ +
+ +

+ +

+
+
+
+`; diff --git a/packages/react-instantsearch-dom/src/index.js b/packages/react-instantsearch-dom/src/index.js index 1c15a799ee..1b24c4ec6e 100644 --- a/packages/react-instantsearch-dom/src/index.js +++ b/packages/react-instantsearch-dom/src/index.js @@ -60,7 +60,11 @@ export { default as Snippet } from './widgets/Snippet'; export { default as SortBy } from './widgets/SortBy'; export { default as Stats } from './widgets/Stats'; export { default as ToggleRefinement } from './widgets/ToggleRefinement'; +export { default as VoiceSearch } from './widgets/VoiceSearch'; export { default as QueryRuleCustomData } from './widgets/QueryRuleCustomData'; // Utils export { createClassNames } from './core/utils'; + +// voiceSearchHelper +export { default as createVoiceSearchHelper } from './lib/voiceSearchHelper'; diff --git a/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/__tests__/index.ts b/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/__tests__/index.ts new file mode 100644 index 0000000000..a41cbd873a --- /dev/null +++ b/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/__tests__/index.ts @@ -0,0 +1,196 @@ +// copied from https://github.com/algolia/instantsearch.js/blob/0e988cc85487f61aa3b61131c22bed135ddfd76d/src/lib/voiceSearchHelper/__tests__/index-test.ts + +import createVoiceSearchHelper from '..'; + +type DummySpeechRecognition = () => void; +declare global { + interface Window { + webkitSpeechRecognition?: SpeechRecognition | DummySpeechRecognition; + SpeechRecognition?: SpeechRecognition | DummySpeechRecognition; + } +} + +const start = jest.fn(); +const stop = jest.fn(); + +const createFakeSpeechRecognition = (): jest.Mock => { + const simulateListener: any = {}; + const mock = jest.fn().mockImplementation(() => ({ + start, + stop, + addEventListener(eventName: string, callback: () => void) { + simulateListener[eventName] = callback; + }, + removeEventListener() {}, + })); + (mock as any).simulateListener = simulateListener; + return mock; +}; + +describe('VoiceSearchHelper', () => { + afterEach(() => { + delete window.webkitSpeechRecognition; + delete window.SpeechRecognition; + }); + + it('has initial state correctly', () => { + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: false, + onQueryChange: () => {}, + onStateChange: () => {}, + }); + expect(voiceSearchHelper.getState()).toEqual({ + errorCode: undefined, + isSpeechFinal: false, + status: 'initial', + transcript: '', + }); + }); + + it('is not supported', () => { + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: false, + onQueryChange: () => {}, + onStateChange: () => {}, + }); + expect(voiceSearchHelper.isBrowserSupported()).toBe(false); + }); + + it('is not listening', () => { + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: false, + onQueryChange: () => {}, + onStateChange: () => {}, + }); + expect(voiceSearchHelper.isListening()).toBe(false); + }); + + it('is supported with webkitSpeechRecognition', () => { + window.webkitSpeechRecognition = () => {}; + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: false, + onQueryChange: () => {}, + onStateChange: () => {}, + }); + expect(voiceSearchHelper.isBrowserSupported()).toBe(true); + }); + + it('is supported with SpeechRecognition', () => { + window.SpeechRecognition = createFakeSpeechRecognition(); + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: false, + onQueryChange: () => {}, + onStateChange: () => {}, + }); + expect(voiceSearchHelper.isBrowserSupported()).toBe(true); + }); + + it('works with mock SpeechRecognition (searchAsYouSpeak:false)', () => { + window.SpeechRecognition = createFakeSpeechRecognition(); + const { simulateListener } = window.SpeechRecognition as any; + const onQueryChange = jest.fn(); + const onStateChange = jest.fn(); + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: false, + onQueryChange, + onStateChange, + }); + + voiceSearchHelper.toggleListening(); + expect(onStateChange).toHaveBeenCalledTimes(1); + expect(voiceSearchHelper.getState().status).toEqual('askingPermission'); + simulateListener.start(); + expect(voiceSearchHelper.getState().status).toEqual('waiting'); + simulateListener.result({ + results: [ + (() => { + const obj = [ + { + transcript: 'Hello World', + }, + ]; + (obj as any).isFinal = true; + return obj; + })(), + ], + }); + expect(voiceSearchHelper.getState().status).toEqual('recognizing'); + expect(voiceSearchHelper.getState().transcript).toEqual('Hello World'); + expect(voiceSearchHelper.getState().isSpeechFinal).toBe(true); + expect(onQueryChange).toHaveBeenCalledTimes(0); + simulateListener.end(); + expect(onQueryChange).toHaveBeenCalledWith('Hello World'); + expect(voiceSearchHelper.getState().status).toEqual('finished'); + }); + + it('works with mock SpeechRecognition (searchAsYouSpeak:true)', () => { + window.SpeechRecognition = createFakeSpeechRecognition(); + const { simulateListener } = window.SpeechRecognition as any; + const onQueryChange = jest.fn(); + const onStateChange = jest.fn(); + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: true, + onQueryChange, + onStateChange, + }); + + voiceSearchHelper.toggleListening(); + expect(onStateChange).toHaveBeenCalledTimes(1); + expect(voiceSearchHelper.getState().status).toEqual('askingPermission'); + simulateListener.start(); + expect(voiceSearchHelper.getState().status).toEqual('waiting'); + simulateListener.result({ + results: [ + (() => { + const obj = [ + { + transcript: 'Hello World', + }, + ]; + (obj as any).isFinal = true; + return obj; + })(), + ], + }); + expect(voiceSearchHelper.getState().status).toEqual('recognizing'); + expect(voiceSearchHelper.getState().transcript).toEqual('Hello World'); + expect(voiceSearchHelper.getState().isSpeechFinal).toBe(true); + expect(onQueryChange).toHaveBeenCalledWith('Hello World'); + simulateListener.end(); + expect(onQueryChange).toHaveBeenCalledTimes(1); + expect(voiceSearchHelper.getState().status).toEqual('finished'); + }); + + it('works with onerror', () => { + window.SpeechRecognition = createFakeSpeechRecognition(); + const { simulateListener } = window.SpeechRecognition as any; + const onQueryChange = jest.fn(); + const onStateChange = jest.fn(); + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: true, + onQueryChange, + onStateChange, + }); + voiceSearchHelper.toggleListening(); + expect(voiceSearchHelper.getState().status).toEqual('askingPermission'); + simulateListener.error({ + error: 'not-allowed', + }); + expect(voiceSearchHelper.getState().status).toEqual('error'); + expect(voiceSearchHelper.getState().errorCode).toEqual('not-allowed'); + simulateListener.end(); + expect(onQueryChange).toHaveBeenCalledTimes(0); + }); + + it('stops listening on `dispose`', () => { + window.SpeechRecognition = createFakeSpeechRecognition(); + const voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak: false, + onQueryChange: () => {}, + onStateChange: () => {}, + }); + voiceSearchHelper.toggleListening(); + voiceSearchHelper.dispose(); + expect(stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/index.ts b/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/index.ts new file mode 100644 index 0000000000..4a89155ca6 --- /dev/null +++ b/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/index.ts @@ -0,0 +1,162 @@ +// copied from https://github.com/algolia/instantsearch.js/blob/0e988cc85487f61aa3b61131c22bed135ddfd76d/src/lib/voiceSearchHelper/index.ts + +export type VoiceSearchHelperParams = { + searchAsYouSpeak: boolean; + onQueryChange: (query: string) => void; + onStateChange: () => void; +}; + +export type Status = + | 'initial' + | 'askingPermission' + | 'waiting' + | 'recognizing' + | 'finished' + | 'error'; + +export type ErrorCode = + | 'no-speech' + | 'aborted' + | 'audio-capture' + | 'network' + | 'not-allowed' + | 'service-not-allowed' + | 'bad-grammar' + | 'language-not-supported'; + +export type VoiceListeningState = { + status: Status; + transcript: string; + isSpeechFinal: boolean; + errorCode?: ErrorCode; +}; + +export type VoiceSearchHelper = { + getState: () => VoiceListeningState; + isBrowserSupported: () => boolean; + isListening: () => boolean; + toggleListening: () => void; + dispose: () => void; +}; + +export type ToggleListening = () => void; + +export default function createVoiceSearchHelper({ + searchAsYouSpeak, + onQueryChange, + onStateChange, +}: VoiceSearchHelperParams): VoiceSearchHelper { + const SpeechRecognitionAPI: new () => SpeechRecognition = + (window as any).webkitSpeechRecognition || + (window as any).SpeechRecognition; + const getDefaultState = (status: Status): VoiceListeningState => ({ + status, + transcript: '', + isSpeechFinal: false, + errorCode: undefined, + }); + let state: VoiceListeningState = getDefaultState('initial'); + let recognition: SpeechRecognition | undefined; + + const isBrowserSupported = (): boolean => Boolean(SpeechRecognitionAPI); + + const isListening = (): boolean => + state.status === 'askingPermission' || + state.status === 'waiting' || + state.status === 'recognizing'; + + const setState = (newState: Partial = {}): void => { + state = { ...state, ...newState }; + onStateChange(); + }; + + const getState = (): VoiceListeningState => state; + + const resetState = (status: Status = 'initial'): void => { + setState(getDefaultState(status)); + }; + + const onStart = (): void => { + setState({ + status: 'waiting', + }); + }; + + const onError = (event: SpeechRecognitionError): void => { + setState({ status: 'error', errorCode: event.error }); + }; + + const onResult = (event: SpeechRecognitionEvent): void => { + setState({ + status: 'recognizing', + transcript: + (event.results[0] && + event.results[0][0] && + event.results[0][0].transcript) || + '', + isSpeechFinal: event.results[0] && event.results[0].isFinal, + }); + if (searchAsYouSpeak && state.transcript) { + onQueryChange(state.transcript); + } + }; + + const onEnd = (): void => { + if (!state.errorCode && state.transcript && !searchAsYouSpeak) { + onQueryChange(state.transcript); + } + if (state.status !== 'error') { + setState({ status: 'finished' }); + } + }; + + const stop = (): void => { + dispose(); + resetState(); + }; + + const start = (): void => { + recognition = new SpeechRecognitionAPI(); + if (!recognition) { + return; + } + resetState('askingPermission'); + recognition.interimResults = true; + recognition.addEventListener('start', onStart); + recognition.addEventListener('error', onError); + recognition.addEventListener('result', onResult); + recognition.addEventListener('end', onEnd); + recognition.start(); + }; + + const dispose = (): void => { + if (!recognition) { + return; + } + recognition.stop(); + recognition.removeEventListener('start', onStart); + recognition.removeEventListener('error', onError); + recognition.removeEventListener('result', onResult); + recognition.removeEventListener('end', onEnd); + recognition = undefined; + }; + + const toggleListening = (): void => { + if (!isBrowserSupported()) { + return; + } + if (isListening()) { + stop(); + } else { + start(); + } + }; + + return { + getState, + isBrowserSupported, + isListening, + toggleListening, + dispose, + }; +} diff --git a/packages/react-instantsearch-dom/src/widgets/VoiceSearch.ts b/packages/react-instantsearch-dom/src/widgets/VoiceSearch.ts new file mode 100644 index 0000000000..d671030e87 --- /dev/null +++ b/packages/react-instantsearch-dom/src/widgets/VoiceSearch.ts @@ -0,0 +1,4 @@ +import { connectSearchBox } from 'react-instantsearch-core'; +import VoiceSearch from '../components/VoiceSearch'; + +export default connectSearchBox(VoiceSearch); diff --git a/stories/VoiceSearch.stories.tsx b/stories/VoiceSearch.stories.tsx new file mode 100644 index 0000000000..17266e1db6 --- /dev/null +++ b/stories/VoiceSearch.stories.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { VoiceSearch, SearchBox } from '../packages/react-instantsearch-dom'; +import { WrapWithHits } from './util'; + +const stories = storiesOf('VoiceSearch', module); + +stories + .add('default', () => ( + +

+ To see this button disabled, test it on unsupported browsers like + Safari, Firefox, etc. +

+ +
+ )) + .add('without status', () => ( + + null} /> + + )) + .add('with a SearchBox', () => ( + + + + + )) + .add('with a custom button text', () => ( + +
+ (isListening ? '⏹' : '🎙')} + /> +
+
+ )) + .add('with full status', () => { + const Status = ({ + status, + errorCode, + isListening, + transcript, + isSpeechFinal, + isBrowserSupported, + }) => { + return ( +
+

status: {status}

+

errorCode: {errorCode}

+

isListening: {isListening ? 'true' : 'false'}

+

transcript: {transcript}

+

isSpeechFinal: {isSpeechFinal ? 'true' : 'false'}

+

isBrowserSupported: {isBrowserSupported ? 'true' : 'false'}

+
+ ); + }; + + return ( + + + + ); + }) + .add('search as you speak', () => { + const Status = ({ + status, + errorCode, + isListening, + transcript, + isSpeechFinal, + isBrowserSupported, + }) => { + return ( +
+

status: {status}

+

errorCode: {errorCode}

+

isListening: {isListening ? 'true' : 'false'}

+

transcript: {transcript}

+

isSpeechFinal: {isSpeechFinal ? 'true' : 'false'}

+

isBrowserSupported: {isBrowserSupported ? 'true' : 'false'}

+
+ ); + }; + return ( + + + + ); + }) + .add('example of dynamic UI working with SearchBox', () => { + const Status = ({ isListening, transcript }) => { + return ( +
+ {transcript} +
+ ); + }; + + return ( + +
+ + +
+
+ ); + }); diff --git a/storybook/public/util.css b/storybook/public/util.css index 206fd9e42c..fd737396df 100644 --- a/storybook/public/util.css +++ b/storybook/public/util.css @@ -191,3 +191,34 @@ .my-custom-marker--active::after { background-color: #3369e7; } + +/* Voice Search */ +.custom-button-story .ais-VoiceSearch-button:hover { + background: inherit; +} + +.custom-ui .ais-VoiceSearch-button { + position: absolute; + right: 43px; + top: 53px; + z-index: 3; +} +.custom-ui .ais-VoiceSearch-status .layer { + position: absolute; + background: rgba(255, 255, 255, 0.95); + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 2; + align-items: center; + justify-content: center; + display: none; +} +.custom-ui .ais-VoiceSearch-status .layer.listening-true { + display: flex; +} +.custom-ui .ais-VoiceSearch-status .layer span { + font-size: 2rem; + color: #555; +}