Skip to content
This repository has been archived by the owner on Dec 30, 2022. It is now read-only.

feat(voiceSearch): add voice search component #668

Merged
merged 19 commits into from
May 21, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .storybook/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,21 +108,21 @@ body {
min-height: unset !important;
}

input {
input:not([class^='ais-']) {
border-style: solid !important;
border-width: 1px;
border-color: #c4c8d8;
border-radius: 5px !important;
}

button {
button:not([class^='ais-']) {
border-style: solid !important;
border-width: 1px;
padding: 0.2em;
border-color: black;
border-radius: 5px !important;
}

button:disabled {
button:not([class^='ais-']):disabled {
border-color: #c4c8d8;
}
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
},
"dependencies": {
"algoliasearch-helper": "^2.26.1",
"instantsearch.js": "^3.4.0"
"instantsearch.js": "^3.5.1"
},
"peerDependencies": {
"algoliasearch": "^3.30.0",
Expand Down Expand Up @@ -101,15 +101,15 @@
"bundlesize": [
{
"path": "./dist/vue-instantsearch.js",
"maxSize": "67.25 kB"
"maxSize": "68.60 kB"
},
{
"path": "./dist/vue-instantsearch.esm.js",
"maxSize": "14 kB"
"maxSize": "14.25 kB"
},
{
"path": "./dist/vue-instantsearch.common.js",
"maxSize": "14 kB"
"maxSize": "14.50 kB"
}
],
"jest": {
Expand Down
160 changes: 160 additions & 0 deletions src/components/VoiceSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<template>
<div
v-if="state"
:class="suit()"
>
<slot v-bind="rootSlotProps">
<button
type="button"
:class="suit('button')"
:title="state.isBrowserSupported ? buttonTitle : disabledButtonTitle"
:disabled="!state.isBrowserSupported"
@click="handleClick"
>
<slot
name="buttonText"
v-bind="innerSlotProps"
>
<svg
v-bind="buttonSvgAttrs"
v-if="errorNotAllowed"
>
<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"
/>
</svg>
<svg
v-bind="buttonSvgAttrs"
v-else
>
<path
d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"
:fill="state.isListening ? 'currentColor' : 'none'"
/>
<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"
/>
</svg>
</slot>
</button>
<div :class="suit('status')">
<slot
name="status"
v-bind="innerSlotProps"
>
<p>{{ state.voiceListeningState.transcript }}</p>
</slot>
</div>
</slot>
</div>
</template>

<script>
import { connectVoiceSearch } from 'instantsearch.js/es/connectors';
import { createSuitMixin } from '../mixins/suit';
import { createWidgetMixin } from '../mixins/widget';

export default {
name: 'AisVoiceSearch',
mixins: [
createWidgetMixin({ connector: connectVoiceSearch }),
createSuitMixin({ name: 'VoiceSearch' }),
],
props: {
searchAsYouSpeak: {
type: Boolean,
required: false,
default: false,
},
buttonTitle: {
type: String,
required: false,
default: 'Search by voice',
},
disabledButtonTitle: {
type: String,
required: false,
default: 'Search by voice (not supported on this browser)',
},
},
data() {
return {
buttonSvgAttrs: {
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',
},
};
},
computed: {
widgetParams() {
return {
searchAsYouSpeak: this.searchAsYouSpeak,
};
},
errorNotAllowed() {
return (
this.state.voiceListeningState.status === 'error' &&
this.state.voiceListeningState.errorCode === 'not-allowed'
);
},
rootSlotProps() {
return {
isBrowserSupported: this.state.isBrowserSupported,
isListening: this.state.isListening,
toggleListening: this.state.toggleListening,
voiceListeningState: this.state.voiceListeningState,
};
},
innerSlotProps() {
eunjae-lee marked this conversation as resolved.
Show resolved Hide resolved
return {
status: this.state.voiceListeningState.status,
errorCode: this.state.voiceListeningState.errorCode,
isListening: this.state.isListening,
transcript: this.state.voiceListeningState.transcript,
isSpeechFinal: this.state.voiceListeningState.isSpeechFinal,
isBrowserSupported: this.state.isBrowserSupported,
};
},
},
methods: {
handleClick(event) {
event.currentTarget.blur();
this.state.toggleListening();
},
},
};
</script>
103 changes: 103 additions & 0 deletions src/components/__tests__/VoiceSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import VoiceSearch from '../VoiceSearch.vue';
import { mount } from '@vue/test-utils';
import { __setState } from '../../mixins/widget';
jest.mock('../../mixins/widget');

const defaultState = {
voiceListeningState: {
status: 'initial',
transcript: undefined,
isSpeechFinal: undefined,
errorCode: undefined,
},
isBrowserSupported: true,
isListening: false,
toggleListening: jest.fn(),
};

const buttonTextScopedSlot = `
<span slot-scope="{ isListening }">
{{isListening ? "Stop": "Start"}}
</span>
`;

describe('button', () => {
it('calls toggleListening when the button is clicked', () => {
__setState(defaultState);
const wrapper = mount(VoiceSearch);
wrapper.find('button').trigger('click');
expect(wrapper.vm.state.toggleListening).toHaveBeenCalledTimes(1);
});
});

describe('Rendering', () => {
it('renders default template correctly', () => {
__setState(defaultState);
const wrapper = mount(VoiceSearch);
expect(wrapper.html()).toMatchSnapshot();
});

it('display the button as disabled on unsupported browser', () => {
__setState({
...defaultState,
isBrowserSupported: false,
});
const wrapper = mount(VoiceSearch);
expect(wrapper.find('button').attributes().disabled).toBe('disabled');
});

it('with custom template for buttonText (1)', () => {
__setState({
...defaultState,
isListening: true,
});
const wrapper = mount(VoiceSearch, {
scopedSlots: {
buttonText: buttonTextScopedSlot,
},
});
expect(wrapper.find('button').text()).toBe('Stop');
});

it('with custom template for buttonText (2)', () => {
__setState({
...defaultState,
isListening: false,
});
const wrapper = mount(VoiceSearch, {
scopedSlots: {
buttonText: buttonTextScopedSlot,
},
});
expect(wrapper.find('button').text()).toBe('Start');
});

it('with custom template for status', () => {
__setState({
voiceListeningState: {
status: 'recognizing',
transcript: 'Hello',
isSpeechFinal: false,
errorCode: undefined,
},
isBrowserSupported: true,
isListening: true,
toggleListening: jest.fn(),
});
const wrapper = mount(VoiceSearch, {
scopedSlots: {
status: `
<div slot-scope="{ status, errorCode, isListening, transcript, isSpeechFinal, isBrowserSupported }">
<p>status: {{status}}</p>
<p>errorCode: {{errorCode}}</p>
<p>isListening: {{isListening}}</p>
<p>transcript: {{transcript}}</p>
<p>isSpeechFinal: {{isSpeechFinal}}</p>
<p>isBrowserSupported: {{isBrowserSupported}}</p>
</div>
`,
},
});
expect(wrapper.find('.ais-VoiceSearch-status').html()).toMatchSnapshot();
});
});
73 changes: 73 additions & 0 deletions src/components/__tests__/__snapshots__/VoiceSearch.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Rendering renders default template correctly 1`] = `

<div class="ais-VoiceSearch">
<button type="button"
title="Search by voice"
class="ais-VoiceSearch-button"
>
<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"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"
fill="none"
>
</path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2">
</path>
<line x1="12"
y1="19"
x2="12"
y2="23"
>
</line>
<line x1="8"
y1="23"
x2="16"
y2="23"
>
</line>
</svg>
</button>
<div class="ais-VoiceSearch-status">
<p>
</p>
</div>
</div>

`;

exports[`Rendering with custom template for status 1`] = `

<div class="ais-VoiceSearch-status">
<div>
<p>
status: recognizing
</p>
<p>
errorCode:
</p>
<p>
isListening: true
</p>
<p>
transcript: Hello
</p>
<p>
isSpeechFinal: false
</p>
<p>
isBrowserSupported: true
</p>
</div>
</div>

`;
1 change: 1 addition & 0 deletions src/widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ export { default as AisStats } from './components/Stats.vue';
export {
default as AisToggleRefinement,
} from './components/ToggleRefinement.vue';
export { default as AisVoiceSearch } from './components/VoiceSearch.vue';
Loading