Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Debounce source function by debounceMs on keystroke #611

Closed
wants to merge 5 commits into from
Closed
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,15 @@ Type: `string`

Use this property to override the [BEM](http://getbem.com/) block name that the JavaScript component will use. You will need to rewrite the CSS class names to use your specified block name.

#### `debounceMs` (default: `0`)

Type: `number`

When set to a value above 0, wait this number of ms after the last input keystroke before calling `source`.
If a second key is pressed within the interval, the timer is reset. This allows you to limit the number of
requests the autocomplete will make – for example, when `source` talks to an expensive API and one request
per keystroke with a large number of users would overwhelm the server.

#### `defaultValue` (default: `''`)

Type: `string`
Expand Down
2 changes: 1 addition & 1 deletion dist/accessible-autocomplete.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/accessible-autocomplete.min.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/lib/accessible-autocomplete.preact.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/lib/accessible-autocomplete.preact.min.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/lib/accessible-autocomplete.react.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/lib/accessible-autocomplete.react.min.js.map

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ <h3><code>{ minLength: 2 }</code></h3>
<label for="autocomplete-minLength">Country</label>
<div id="tt-minLength" class="autocomplete-wrapper"></div>

<h3><code>{ debounceMs: 250 }</code></h3>
<p>
This option will prevent displaying suggestions until debounceMs has elapsed
</p>
<label for="autocomplete-debounceMs">Country</label>
<div id="tt-debounceMs" class="autocomplete-wrapper"></div>

<h3><code>{ displayMenu: 'overlay' }</code></h3>
<p>This option will display the menu as an absolutely positioned overlay.</p>
<label for="autocomplete-overlay">Country</label>
Expand Down Expand Up @@ -454,6 +461,20 @@ <h3>Translating texts</h3>
})
</script>

<script type="text/javascript">
element = document.querySelector('#tt-debounceMs')
id = 'autocomplete-debounceMs'
accessibleAutocomplete({
element: element,
id: id,
source: countries,
debounceMs: 250,
menuAttributes: {
"aria-labelledby": id
}
})
</script>

<script type="text/javascript">
element = document.querySelector('#tt-autoselect')
id = 'autocomplete-autoselect'
Expand Down
17 changes: 13 additions & 4 deletions src/autocomplete.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createElement, Component } from 'preact' /** @jsx createElement */
import { debounce } from './debounce'
import Status from './status'
import DropdownArrowDown from './dropdown-arrow-down'

Expand Down Expand Up @@ -41,6 +42,7 @@ export default class Autocomplete extends Component {
defaultValue: '',
displayMenu: 'inline',
minLength: 0,
debounceMs: 0,
name: 'input-autocomplete',
placeholder: '',
onConfirm: () => {},
Expand All @@ -66,11 +68,15 @@ export default class Autocomplete extends Component {
menuOpen: false,
options: props.defaultValue ? [props.defaultValue] : [],
query: props.defaultValue,
debouncing: false,
validChoiceMade: false,
selected: null,
ariaHint: true
}

const { source, debounceMs } = this.props
this.debouncedSource = debounceMs > 0 ? debounce(source, debounceMs) : source

this.handleComponentBlur = this.handleComponentBlur.bind(this)
this.handleKeyDown = this.handleKeyDown.bind(this)
this.handleUpArrow = this.handleUpArrow.bind(this)
Expand Down Expand Up @@ -212,7 +218,7 @@ export default class Autocomplete extends Component {
}

handleInputChange (event) {
const { minLength, source, showAllValues } = this.props
const { minLength, showAllValues } = this.props
const autoselect = this.hasAutoselect()
const query = event.target.value
const queryEmpty = query.length === 0
Expand All @@ -226,9 +232,11 @@ export default class Autocomplete extends Component {

const searchForOptions = showAllValues || (!queryEmpty && queryChanged && queryLongEnough)
if (searchForOptions) {
source(query, (options) => {
this.setState({ debouncing: true })
this.debouncedSource(query, (options) => {
const optionsAvailable = options.length > 0
this.setState({
debouncing: false,
menuOpen: optionsAvailable,
options,
selected: (autoselect && optionsAvailable) ? 0 : -1,
Expand Down Expand Up @@ -422,7 +430,7 @@ export default class Autocomplete extends Component {
menuAttributes,
inputClasses
} = this.props
const { focused, hovered, menuOpen, options, query, selected, ariaHint, validChoiceMade } = this.state
const { focused, hovered, menuOpen, options, debouncing, query, selected, ariaHint, validChoiceMade } = this.state
const autoselect = this.hasAutoselect()

const inputFocused = focused === -1
Expand Down Expand Up @@ -495,6 +503,7 @@ export default class Autocomplete extends Component {
length={options.length}
queryLength={query.length}
minQueryLength={minLength}
autocompleteDebouncing={debouncing}
selectedOption={this.templateInputValue(options[selected])}
selectedOptionIndex={selected}
validChoiceMade={validChoiceMade}
Expand Down Expand Up @@ -567,7 +576,7 @@ export default class Autocomplete extends Component {
)
})}

{showNoOptionsFound && (
{showNoOptionsFound && !debouncing && (
<li className={`${optionClassName} ${optionClassName}--no-results`}>{tNoResults()}</li>
)}
</ul>
Expand Down
15 changes: 15 additions & 0 deletions src/debounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const debounce = function (func, wait, immediate) {
let timeout
return function () {
const context = this
const args = arguments
const later = function () {
timeout = null
if (!immediate) func.apply(context, args)
}
const callNow = immediate && !timeout
clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func.apply(context, args)
}
}
19 changes: 3 additions & 16 deletions src/status.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,11 @@
import { createElement, Component } from 'preact' /** @jsx createElement */
import { debounce } from './debounce'

const debounce = function (func, wait, immediate) {
let timeout
return function () {
const context = this
const args = arguments
const later = function () {
timeout = null
if (!immediate) func.apply(context, args)
}
const callNow = immediate && !timeout
clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func.apply(context, args)
}
}
const statusDebounceMillis = 1400

export default class Status extends Component {
static defaultProps = {
autocompleteDebouncing: false,
tQueryTooShort: (minQueryLength) => `Type in ${minQueryLength} or more characters for results`,
tNoResults: () => 'No search results',
tSelectedOption: (selectedOption, length, index) => `${selectedOption} ${index + 1} of ${length} is highlighted`,
Expand All @@ -42,7 +29,7 @@ export default class Status extends Component {
const that = this
this.debounceStatusUpdate = debounce(function () {
if (!that.state.debounced) {
const shouldSilence = !that.props.isInFocus || that.props.validChoiceMade
const shouldSilence = !that.props.isInFocus || that.props.validChoiceMade || that.props.autocompleteDebouncing
that.setState(({ bump }) => ({ bump: !bump, debounced: true, silenced: shouldSilence }))
}
}, statusDebounceMillis)
Expand Down
49 changes: 49 additions & 0 deletions test/functional/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,39 @@ describe('Autocomplete', () => {
})
})

describe('with debounceMs', () => {
beforeEach(() => {
autocomplete = new Autocomplete({
...Autocomplete.defaultProps,
id: 'test',
debounceMs: 150,
source: suggest
})
})

it('doesn\'t search when time has not passed', () => {
autocomplete.handleInputChange({ target: { value: 'fra' } })
expect(autocomplete.state.menuOpen).to.equal(false)
expect(autocomplete.state.options.length).to.equal(0)
expect(autocomplete.state.debouncing).to.equal(true)
})

it('does search when time has passed', (done) => {
autocomplete.handleInputChange({ target: { value: 'fra' } })

setTimeout(() => {
try {
expect(autocomplete.state.menuOpen).to.equal(true)
expect(autocomplete.state.options).to.contain('France')
expect(autocomplete.state.debouncing).to.equal(false)
done()
} catch (error) {
done(error)
}
}, 300)
})
})

describe('focusing input', () => {
describe('when no query is present', () => {
it('does not display menu', () => {
Expand Down Expand Up @@ -708,6 +741,22 @@ describe('Status', () => {
done()
}, 1500)
})

it('when the parent autocomplete is debouncing', (done) => {
const status = new Status({
...Status.defaultProps,
validChoiceMade: false,
isInFocus: true,
autocompleteDebouncing: true
})
status.componentWillMount()
status.render()

setTimeout(() => {
expect(status.state.silenced).to.equal(true)
done()
}, 1500)
})
})
describe('does not silence aria live announcement', () => {
it('when a valid choice has not been made and the input has focus', (done) => {
Expand Down