From 746bf9372a17c170c2d253f0a6b2848c06fd5020 Mon Sep 17 00:00:00 2001 From: Russell Garner Date: Sat, 18 Nov 2023 11:47:20 +0000 Subject: [PATCH 1/5] Move debounce function to own module Status is using it, but autocomplete will be in a moment. --- src/debounce.js | 15 +++++++++++++++ src/status.js | 16 +--------------- 2 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 src/debounce.js diff --git a/src/debounce.js b/src/debounce.js new file mode 100644 index 00000000..f12e3083 --- /dev/null +++ b/src/debounce.js @@ -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) + } +} diff --git a/src/status.js b/src/status.js index 9c2560f0..409d4041 100644 --- a/src/status.js +++ b/src/status.js @@ -1,20 +1,6 @@ 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 { From 6612e0901d9292519b3ece968b353418e50af96d Mon Sep 17 00:00:00 2001 From: Russell Garner Date: Sat, 18 Nov 2023 17:41:19 +0000 Subject: [PATCH 2/5] Add debounceMs to autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When debounceMs is greater than 0, delay the calling of the `source` function for that number of milliseconds. In the circumstance where the `source` function is talking to an API with a potentially large result set – the querying of which has potential to overload hosting – even a modest value of 200ms or so can halve or quarter the number of queries each user sends without appreciably degrading their experience. When debounceMs is 0, pass the `source` function through unchanged. This commit should not change the behaviour of any existing consumer. --- README.md | 9 +++++++++ src/autocomplete.js | 9 +++++++-- test/functional/index.js | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2e377dff..7b551d59 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/src/autocomplete.js b/src/autocomplete.js index 01b5c623..6425b6c3 100644 --- a/src/autocomplete.js +++ b/src/autocomplete.js @@ -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' @@ -41,6 +42,7 @@ export default class Autocomplete extends Component { defaultValue: '', displayMenu: 'inline', minLength: 0, + debounceMs: 0, name: 'input-autocomplete', placeholder: '', onConfirm: () => {}, @@ -71,6 +73,9 @@ export default class Autocomplete extends Component { 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) @@ -212,7 +217,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 @@ -226,7 +231,7 @@ export default class Autocomplete extends Component { const searchForOptions = showAllValues || (!queryEmpty && queryChanged && queryLongEnough) if (searchForOptions) { - source(query, (options) => { + this.debouncedSource(query, (options) => { const optionsAvailable = options.length > 0 this.setState({ menuOpen: optionsAvailable, diff --git a/test/functional/index.js b/test/functional/index.js index 9bf1768a..4f47a6d6 100644 --- a/test/functional/index.js +++ b/test/functional/index.js @@ -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', () => { From c42a5d64d32d00c24680f68c5b1cdb37e3ba6b07 Mon Sep 17 00:00:00 2001 From: Russell Garner Date: Sun, 19 Nov 2023 13:10:39 +0000 Subject: [PATCH 3/5] Don't show tNoResults when debouncing When a `source` query has yet to be called, it's not necessary to show "no results found". Pass the debouncing state of the autocomplete down to the Status component through Status.props.autocompleteDebouncing such that the status component does not announce "no results available" immediately before announcing "50 results available". From the autocomplete, this is difficult to test. Tests already exist showing that `this.state.debouncing` is set appropriately when `debounceMs` is in use. It is used in conjunction with the `showNoOptionsFound` prop to suppress tNoResults in render(). No good option exists for testing it that doesn't involve extra redundant state to service the tests. It can't be done by rendering tests alone, and it can't be done by behaviour tests alone. --- src/autocomplete.js | 8 ++++++-- src/status.js | 3 ++- test/functional/index.js | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/autocomplete.js b/src/autocomplete.js index 6425b6c3..2fbdf019 100644 --- a/src/autocomplete.js +++ b/src/autocomplete.js @@ -68,6 +68,7 @@ export default class Autocomplete extends Component { menuOpen: false, options: props.defaultValue ? [props.defaultValue] : [], query: props.defaultValue, + debouncing: false, validChoiceMade: false, selected: null, ariaHint: true @@ -231,9 +232,11 @@ export default class Autocomplete extends Component { const searchForOptions = showAllValues || (!queryEmpty && queryChanged && queryLongEnough) if (searchForOptions) { + 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, @@ -427,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 @@ -500,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} @@ -572,7 +576,7 @@ export default class Autocomplete extends Component { ) })} - {showNoOptionsFound && ( + {showNoOptionsFound && !debouncing && (
  • {tNoResults()}
  • )} diff --git a/src/status.js b/src/status.js index 409d4041..57802db9 100644 --- a/src/status.js +++ b/src/status.js @@ -5,6 +5,7 @@ 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`, @@ -28,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) diff --git a/test/functional/index.js b/test/functional/index.js index 4f47a6d6..f7d7c13f 100644 --- a/test/functional/index.js +++ b/test/functional/index.js @@ -741,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) => { From 7f877d8507cf47500aa3b926011f9c251579c29b Mon Sep 17 00:00:00 2001 From: Russell Garner Date: Mon, 20 Nov 2023 17:56:53 +0000 Subject: [PATCH 4/5] Add debounceMs example --- examples/index.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/examples/index.html b/examples/index.html index 1c91af40..2b5edd16 100644 --- a/examples/index.html +++ b/examples/index.html @@ -70,6 +70,13 @@

    { minLength: 2 }

    +

    { debounceMs: 250 }

    +

    + This option will prevent displaying suggestions until debounceMs has elapsed +

    + +
    +

    { displayMenu: 'overlay' }

    This option will display the menu as an absolutely positioned overlay.

    @@ -454,6 +461,20 @@

    Translating texts

    }) + +