From baeff8f59c90b2583950c223c91f4c3074948a4a Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 2 Jan 2018 17:55:25 -0500 Subject: [PATCH] Don't autocomplete a longer value if search matches a value exactly (closes #4549) --- modules/lib/d3.combobox.js | 111 +++++++++++++++++++---------------- test/spec/lib/d3.combobox.js | 57 +++++++++++------- 2 files changed, 97 insertions(+), 71 deletions(-) diff --git a/modules/lib/d3.combobox.js b/modules/lib/d3.combobox.js index baa9d4cb4..92cd0a736 100644 --- a/modules/lib/d3.combobox.js +++ b/modules/lib/d3.combobox.js @@ -14,15 +14,15 @@ import { export function d3combobox() { - var dispatch = d3_dispatch('accept'), - container = d3_select(document.body), - data = [], - suggestions = [], - minItems = 2, - caseSensitive = false; + var dispatch = d3_dispatch('accept'); + var _container = d3_select(document.body); + var _data = []; + var _suggestions = []; + var _minItems = 2; + var _caseSensitive = false; - var fetcher = function(val, cb) { - cb(data.filter(function(d) { + var _fetcher = function(val, cb) { + cb(_data.filter(function(d) { return d.value .toString() .toLowerCase() @@ -31,11 +31,11 @@ export function d3combobox() { }; var combobox = function(input, attachTo) { - var idx = -1, - wrapper = container - .selectAll('div.combobox') - .filter(function(d) { return d === input.node(); }), - shown = !wrapper.empty(); + var idx = -1; + var wrapper = _container + .selectAll('div.combobox') + .filter(function(d) { return d === input.node(); }); + var shown = !wrapper.empty(); input .classed('combobox-input', true) @@ -45,17 +45,17 @@ export function d3combobox() { .on('keyup.typeahead', keyup) .on('input.typeahead', change) .each(function() { - var parent = this.parentNode, - sibling = this.nextSibling; + var parent = this.parentNode; + var sibling = this.nextSibling; var caret = d3_select(parent).selectAll('.combobox-caret') .filter(function(d) { return d === input.node(); }) .data([input.node()]); caret = caret.enter() - .insert('div', function() { return sibling; }) + .insert('div', function() { return sibling; }) .attr('class', 'combobox-caret') - .merge(caret); + .merge(caret); caret .on('mousedown', function () { @@ -82,7 +82,7 @@ export function d3combobox() { function show() { if (!shown) { - wrapper = container + wrapper = _container .insert('div', ':first-child') .datum(input.node()) .attr('class', 'combobox') @@ -177,17 +177,17 @@ export function d3combobox() { } function nav(dir) { - if (!suggestions.length) return; - idx = Math.max(Math.min(idx + dir, suggestions.length - 1), 0); - input.property('value', suggestions[idx].value); + if (!_suggestions.length) return; + idx = Math.max(Math.min(idx + dir, _suggestions.length - 1), 0); + input.property('value', _suggestions[idx].value); render(); ensureVisible(); } function value() { - var value = input.property('value'), - start = input.property('selectionStart'), - end = input.property('selectionEnd'); + var value = input.property('value'); + var start = input.property('selectionStart'); + var end = input.property('selectionEnd'); if (start && end) { value = value.substring(0, start); @@ -197,32 +197,45 @@ export function d3combobox() { } function fetch(v, cb) { - fetcher.call(input, v, function(_) { - suggestions = _; + _fetcher.call(input, v, function(_) { + _suggestions = _; cb(); }); } function autocomplete() { - var v = caseSensitive ? value() : value().toLowerCase(); + var v = _caseSensitive ? value() : value().toLowerCase(); idx = -1; if (!v) return; - for (var i = 0; i < suggestions.length; i++) { - var suggestion = suggestions[i].value, - compare = caseSensitive ? suggestion : suggestion.toLowerCase(); + var best = -1; + var suggestion, compare; - if (compare.indexOf(v) === 0) { - idx = i; - input.property('value', suggestion); - input.node().setSelectionRange(v.length, suggestion.length); - return; + for (var i = 0; i < _suggestions.length; i++) { + suggestion = _suggestions[i].value; + compare = _caseSensitive ? suggestion : suggestion.toLowerCase(); + + // if search string matches suggestion exactly, pick it.. + if (compare === v) { + best = i; + break; + + // otherwise lock in the first result that starts with the search string.. + } else if (best === -1 && compare.indexOf(v) === 0) { + best = i; } } + + if (best !== -1) { + idx = best; + suggestion = _suggestions[best].value; + input.property('value', suggestion); + input.node().setSelectionRange(v.length, suggestion.length); + } } function render() { - if (suggestions.length >= minItems && document.activeElement === input.node()) { + if (_suggestions.length >= _minItems && document.activeElement === input.node()) { show(); } else { hide(); @@ -231,7 +244,7 @@ export function d3combobox() { var options = wrapper .selectAll('a.combobox-option') - .data(suggestions, function(d) { return d.value; }); + .data(_suggestions, function(d) { return d.value; }); options.exit() .remove(); @@ -248,8 +261,8 @@ export function d3combobox() { .order(); - var node = attachTo ? attachTo.node() : input.node(), - rect = node.getBoundingClientRect(); + var node = attachTo ? attachTo.node() : input.node(); + var rect = node.getBoundingClientRect(); wrapper .style('left', rect.left + 'px') @@ -277,32 +290,32 @@ export function d3combobox() { }; combobox.fetcher = function(_) { - if (!arguments.length) return fetcher; - fetcher = _; + if (!arguments.length) return _fetcher; + _fetcher = _; return combobox; }; combobox.data = function(_) { - if (!arguments.length) return data; - data = _; + if (!arguments.length) return _data; + _data = _; return combobox; }; combobox.minItems = function(_) { - if (!arguments.length) return minItems; - minItems = _; + if (!arguments.length) return _minItems; + _minItems = _; return combobox; }; combobox.caseSensitive = function(_) { - if (!arguments.length) return caseSensitive; - caseSensitive = _; + if (!arguments.length) return _caseSensitive; + _caseSensitive = _; return combobox; }; combobox.container = function(_) { - if (!arguments.length) return container; - container = _; + if (!arguments.length) return _container; + _container = _; return combobox; }; diff --git a/test/spec/lib/d3.combobox.js b/test/spec/lib/d3.combobox.js index b04f839a8..7cc633ca5 100644 --- a/test/spec/lib/d3.combobox.js +++ b/test/spec/lib/d3.combobox.js @@ -2,16 +2,18 @@ describe('d3.combobox', function() { var body, container, content, input, combobox; var data = [ + {title: 'foobar', value: 'foobar'}, {title: 'foo', value: 'foo'}, {title: 'bar', value: 'bar'}, - {title: 'Baz', value: 'Baz'} + {title: 'Baz', value: 'Baz'}, + {title: 'test', value: 'test'} ]; function simulateKeypress(key) { - var keyCode = iD.lib.d3keybinding.keyCodes[key], - value = input.property('value'), - start = input.property('selectionStart'), - finis = input.property('selectionEnd'); + var keyCode = iD.lib.d3keybinding.keyCodes[key]; + var value = input.property('value'); + var start = input.property('selectionStart'); + var finis = input.property('selectionEnd'); iD.d3.customEvent(happen.makeEvent({ type: 'keydown', @@ -101,36 +103,37 @@ describe('d3.combobox', function() { it('shows a menu of entries on focus', function() { input.call(combobox.data(data)); focusTypeahead(input); - expect(body.selectAll('.combobox-option').nodes().length).to.equal(3); - expect(body.selectAll('.combobox-option').text()).to.equal('foo'); + expect(body.selectAll('.combobox-option').nodes().length).to.equal(5); + expect(body.selectAll('.combobox-option').text()).to.equal('foobar'); }); it('filters entries to those matching the value', function() { input.property('value', 'b').call(combobox.data(data)); focusTypeahead(input); - expect(body.selectAll('.combobox-option').size()).to.equal(2); - expect(body.selectAll('.combobox-option').nodes()[0].text).to.equal('bar'); - expect(body.selectAll('.combobox-option').nodes()[1].text).to.equal('Baz'); + expect(body.selectAll('.combobox-option').size()).to.equal(3); + expect(body.selectAll('.combobox-option').nodes()[0].text).to.equal('foobar'); + expect(body.selectAll('.combobox-option').nodes()[1].text).to.equal('bar'); + expect(body.selectAll('.combobox-option').nodes()[2].text).to.equal('Baz'); }); it('shows no menu on focus if it would contain only one item', function() { - input.property('value', 'f').call(combobox.data(data)); + input.property('value', 't').call(combobox.data(data)); focusTypeahead(input); expect(body.selectAll('.combobox-option').size()).to.equal(0); }); it('shows menu on focus if it would contain at least minItems items', function() { combobox.minItems(1); - input.property('value', 'f').call(combobox.data(data)); + input.property('value', 't').call(combobox.data(data)); focusTypeahead(input); expect(body.selectAll('.combobox-option').size()).to.equal(1); }); it('shows all entries when clicking on the caret', function() { - input.property('value', 'foo').call(combobox.data(data)); + input.property('value', 'foobar').call(combobox.data(data)); body.selectAll('.combobox-caret').dispatch('mousedown'); - expect(body.selectAll('.combobox-option').size()).to.equal(3); - expect(body.selectAll('.combobox-option').text()).to.equal('foo'); + expect(body.selectAll('.combobox-option').size()).to.equal(5); + expect(body.selectAll('.combobox-option').text()).to.equal('foobar'); }); it('is initially shown with no selection', function() { @@ -139,7 +142,7 @@ describe('d3.combobox', function() { expect(body.selectAll('.combobox-option.selected').size()).to.equal(0); }); - it('selects the first option matching the input', function() { + it('selects the first option that matches the input', function() { input.call(combobox.data(data)); focusTypeahead(input); simulateKeypress('b'); @@ -147,6 +150,16 @@ describe('d3.combobox', function() { expect(body.selectAll('.combobox-option.selected').text()).to.equal('bar'); }); + it('prefers an option that exactly matches the input over the first option', function() { + input.call(combobox.data(data)); + focusTypeahead(input); + simulateKeypress('f'); + simulateKeypress('o'); + simulateKeypress('o'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(1); + expect(body.selectAll('.combobox-option.selected').text()).to.equal('foo'); // skip foobar + }); + it('selects the completed portion of the value', function() { input.call(combobox.data(data)); focusTypeahead(input); @@ -217,18 +230,18 @@ describe('d3.combobox', function() { simulateKeypress('↓'); expect(body.selectAll('.combobox-option.selected').size()).to.equal(1); - expect(body.selectAll('.combobox-option.selected').text()).to.equal('foo'); - expect(input.property('value')).to.equal('foo'); + expect(body.selectAll('.combobox-option.selected').text()).to.equal('foobar'); + expect(input.property('value')).to.equal('foobar'); simulateKeypress('↓'); expect(body.selectAll('.combobox-option.selected').size()).to.equal(1); - expect(body.selectAll('.combobox-option.selected').text()).to.equal('bar'); - expect(input.property('value')).to.equal('bar'); + expect(body.selectAll('.combobox-option.selected').text()).to.equal('foo'); + expect(input.property('value')).to.equal('foo'); simulateKeypress('↑'); expect(body.selectAll('.combobox-option.selected').size()).to.equal(1); - expect(body.selectAll('.combobox-option.selected').text()).to.equal('foo'); - expect(input.property('value')).to.equal('foo'); + expect(body.selectAll('.combobox-option.selected').text()).to.equal('foobar'); + expect(input.property('value')).to.equal('foobar'); }); it('emits accepted event with selected datum on ⇥', function(done) {