This repository has been archived by the owner on Dec 30, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 386
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <karis612@gmail.com> * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee <karis612@gmail.com> * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee <karis612@gmail.com> * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee <karis612@gmail.com> * Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx Co-Authored-By: eunjae-lee <karis612@gmail.com> * Apply suggestions from code review Co-Authored-By: eunjae-lee <karis612@gmail.com> * 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 <karis612@gmail.com> * 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 <karis612@gmail.com> * 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 <samuel.vllnt@gmail.com> * 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
- Loading branch information
Showing
14 changed files
with
925 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './translatable'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export type Translate = (key: string, ...params: any[]) => string; |
157 changes: 157 additions & 0 deletions
157
packages/react-instantsearch-dom/src/components/VoiceSearch.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<InnerComponentProps>; | ||
statusComponent: React.FC<InnerComponentProps>; | ||
}; | ||
|
||
const ButtonSvg = ({ children }) => ( | ||
<svg | ||
xmlns="http://www.w3.org/2000/svg" | ||
width="16" | ||
height="16" | ||
viewBox="0 0 24 24" | ||
fill="none" | ||
stroke="currentColor" | ||
strokeWidth="2" | ||
strokeLinecap="round" | ||
strokeLinejoin="round" | ||
> | ||
{children} | ||
</svg> | ||
); | ||
|
||
const DefaultButtonText: React.FC<InnerComponentProps> = ({ | ||
status, | ||
errorCode, | ||
isListening, | ||
}) => { | ||
return status === 'error' && errorCode === 'not-allowed' ? ( | ||
<ButtonSvg> | ||
<line x1="1" y1="1" x2="23" y2="23" /> | ||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" /> | ||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" /> | ||
<line x1="12" y1="19" x2="12" y2="23" /> | ||
<line x1="8" y1="23" x2="16" y2="23" /> | ||
</ButtonSvg> | ||
) : ( | ||
<ButtonSvg> | ||
<path | ||
d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" | ||
fill={isListening ? 'currentColor' : ''} | ||
/> | ||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" /> | ||
<line x1="12" y1="19" x2="12" y2="23" /> | ||
<line x1="8" y1="23" x2="16" y2="23" /> | ||
</ButtonSvg> | ||
); | ||
}; | ||
|
||
const DefaultStatus: React.FC<InnerComponentProps> = ({ transcript }) => ( | ||
<p>{transcript}</p> | ||
); | ||
|
||
class VoiceSearch extends Component<VoiceSearchProps, VoiceListeningState> { | ||
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 ( | ||
<div className={cx('')}> | ||
<button | ||
className={cx('button')} | ||
type="button" | ||
title={ | ||
isBrowserSupported() | ||
? translate('buttonTitle') | ||
: translate('disabledButtonTitle') | ||
} | ||
onClick={this.onClick} | ||
disabled={!isBrowserSupported()} | ||
> | ||
<ButtonTextComponent {...innerProps} /> | ||
</button> | ||
<div className={cx('status')}> | ||
<StatusComponent {...innerProps} /> | ||
</div> | ||
</div> | ||
); | ||
} | ||
|
||
public componentWillUnmount() { | ||
if (this.voiceSearchHelper) { | ||
this.voiceSearchHelper.dispose(); | ||
} | ||
} | ||
|
||
private onClick = (event: React.MouseEvent<HTMLElement>) => { | ||
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); |
116 changes: 116 additions & 0 deletions
116
packages/react-instantsearch-dom/src/components/__tests__/VoiceSearch.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(<VoiceSearch />); | ||
wrapper.find('button').simulate('click'); | ||
expect(mockToggleListening).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
|
||
describe('Rendering', () => { | ||
it('with default props', () => { | ||
const wrapper = mount(<VoiceSearch />); | ||
expect(wrapper).toMatchSnapshot(); | ||
}); | ||
|
||
it('with custom component for button with isListening: false', () => { | ||
const customButtonText = ({ isListening }) => | ||
isListening ? 'Stop' : 'Start'; | ||
|
||
const wrapper = mount( | ||
<VoiceSearch buttonTextComponent={customButtonText} /> | ||
); | ||
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( | ||
<VoiceSearch buttonTextComponent={customButtonText} /> | ||
); | ||
expect(wrapper.find('button').text()).toBe('Stop'); | ||
}); | ||
|
||
it('renders a disabled button when the browser is not supported', () => { | ||
mockIsBrowserSupported.mockImplementation(() => false); | ||
const wrapper = mount(<VoiceSearch />); | ||
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, | ||
}) => ( | ||
<div> | ||
<p>status: {status}</p> | ||
<p>errorCode: {errorCode}</p> | ||
<p>isListening: {isListening ? 'true' : 'false'}</p> | ||
<p>transcript: {transcript}</p> | ||
<p>isSpeechFinal: {isSpeechFinal ? 'true' : 'false'}</p> | ||
<p>isBrowserSupported: {isBrowserSupported ? 'true' : 'false'}</p> | ||
</div> | ||
); | ||
|
||
mockIsListening.mockImplementation(() => true); | ||
mockGetState.mockImplementation(() => ({ | ||
status: 'recognizing', | ||
transcript: 'Hello', | ||
isSpeechFinal: false, | ||
errorCode: undefined, | ||
})); | ||
|
||
const wrapper = mount(<VoiceSearch statusComponent={customStatus} />); | ||
expect(wrapper.find('.ais-VoiceSearch-status')).toMatchSnapshot(); | ||
}); | ||
|
||
it('calls voiceSearchHelper.dispose() on unmount', () => { | ||
const wrapper = mount(<VoiceSearch />); | ||
wrapper.find('button').simulate('click'); | ||
wrapper.unmount(); | ||
expect(mockDispose).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.