From aecb07a41a0485fed1d9f39b6a99ae1df09e4897 Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Fri, 21 Jun 2013 15:53:39 -0700 Subject: [PATCH] Refining combobox behavior, with tests --- js/lib/d3.combobox.js | 285 ++++++++++++++++------------------- test/index.html | 1 + test/spec/lib/d3.combobox.js | 183 ++++++++++++++++++++++ 3 files changed, 317 insertions(+), 152 deletions(-) create mode 100644 test/spec/lib/d3.combobox.js diff --git a/js/lib/d3.combobox.js b/js/lib/d3.combobox.js index 94b9eb01b..41355ceec 100644 --- a/js/lib/d3.combobox.js +++ b/js/lib/d3.combobox.js @@ -1,6 +1,7 @@ d3.combobox = function() { var event = d3.dispatch('accept'), - data = []; + data = [], + suggestions = []; var fetcher = function(val, cb) { cb(data.filter(function(d) { @@ -20,6 +21,11 @@ d3.combobox = function() { input .classed('combobox-input', true) + .on('focus.typeahead', focus) + .on('blur.typeahead', blur) + .on('keydown.typeahead', keydown) + .on('keyup.typeahead', keyup) + .on('input.typeahead', change) .each(function() { var parent = this.parentNode, sibling = this.nextSibling; @@ -37,23 +43,16 @@ d3.combobox = function() { // on mousedown d3.event.stopPropagation(); d3.event.preventDefault(); - mousedown(); + input.node().focus(); }); }); - function updateSize() { - var rect = input.node().getBoundingClientRect(); - container.style({ - 'left': rect.left + 'px', - 'width': rect.width + 'px', - 'top': rect.height + rect.top + 'px' - }); + function focus() { + fetch(render); } function blur() { - // hide the combobox whenever the input element - // loses focus - slowHide(); + window.setTimeout(hide, 150); } function show() { @@ -69,7 +68,7 @@ d3.combobox = function() { }); d3.select(document.body) - .on('scroll.combobox', updateSize, true); + .on('scroll.combobox', render, true); shown = true; } @@ -87,15 +86,24 @@ d3.combobox = function() { } } - function slowHide() { - window.setTimeout(hide, 150); - } function keydown() { - if (!shown) return; switch (d3.event.keyCode) { - // down arrow - case 40: - next(); + // backspace, delete + case 8: + case 46: + console.log('keydown backspace'); + input.on('input.typeahead', function() { + idx = -1; + render(); + input.on('input.typeahead', change); + }); + break; + // tab + case 9: + container.selectAll('a.selected').trigger('click'); + break; + // return + case 13: d3.event.preventDefault(); break; // up arrow @@ -103,8 +111,9 @@ d3.combobox = function() { prev(); d3.event.preventDefault(); break; - // escape, tab - case 13: + // down arrow + case 40: + next(); d3.event.preventDefault(); break; } @@ -117,163 +126,135 @@ d3.combobox = function() { case 27: hide(); break; - // escape, tab - case 9: + // return case 13: - if (!shown) return; - accept(); + container.selectAll('a.selected').trigger('click'); break; - default: - update(); - d3.event.preventDefault(); } - d3.event.stopPropagation(); } - function accept() { - if (container.select('a.selected').node()) { - select(container.select('a.selected').datum()); - } - hide(); + function change() { + console.log('input, value=' + input.property('value')); + fetch(function() { + autocomplete(); + render(); + }); } function next() { - var len = container.selectAll('a').data().length; - idx = Math.min(idx + 1, len - 1); - highlight(); + idx = Math.min(idx + 1, suggestions.length - 1); + input.property('value', suggestions[idx].value); + console.log('next ' + idx + ' ' + suggestions[idx].value) + render(); } function prev() { idx = Math.max(idx - 1, 0); - highlight(); + input.property('value', suggestions[idx].value); + console.log('prev ' + idx + ' ' + suggestions[idx].value) + render(); } - var prevValue, prevCompletion; + function value() { + var value = input.property('value'), + start = input.property('selectionStart'), + end = input.property('selectionEnd'); - function highlight() { - container - .selectAll('a') - .classed('selected', function(d, i) { return i == idx; }); - var height = container.node().offsetHeight, - top = container.select('a.selected').node().offsetTop, - selectedHeight = container.select('a.selected').node().offsetHeight; - if ((top + selectedHeight) < height) { - container.node().scrollTop = 0; + if (start && end) { + value = value.substring(0, start); + } + + return value; + } + + function fetch(cb) { + fetcher.call(input, value(), function(_) { + suggestions = _; + cb(); + }); + } + + function autocomplete() { + var v = value(); + + if (!v) { + idx = -1; + return; + } + + for (var i = 0; i < suggestions.length; i++) { + if (suggestions[i].value.toLowerCase().indexOf(v.toLowerCase()) === 0) { + var completion = v + suggestions[i].value.substr(v.length); + idx = i; + input.property('value', completion); + input.node().setSelectionRange(v.length, completion.length); + console.log('autocompleted ' + v + '[' + suggestions[i].value.substr(v.length) + '] ' + v.length + ',' + completion.length); + return; + } + } + } + + function render() { + if (suggestions.length && document.activeElement === input.node()) { + show(); } else { - container.node().scrollTop = top; + hide(); + return; + } + + var options = container + .selectAll('a.combobox-option') + .data(suggestions, function(d) { return d.value; }); + + options.enter().append('a') + .attr('class', 'combobox-option') + .text(function(d) { return d.value; }); + + options + .attr('title', function(d) { return d.title; }) + .classed('selected', function(d, i) { return i == idx; }) + .on('mouseover', select) + .on('click', accept) + .order(); + + options.exit() + .remove(); + + var rect = input.node().getBoundingClientRect(); + + container.style({ + 'left': rect.left + 'px', + 'width': rect.width + 'px', + 'top': rect.height + rect.top + 'px' + }); + + if (idx >= 0) { + var height = container.node().offsetHeight, + top = container.selectAll('a.selected').node().offsetTop, + selectedHeight = container.selectAll('a.selected').node().offsetHeight; + + if ((top + selectedHeight) < height) { + container.node().scrollTop = 0; + } else { + container.node().scrollTop = top; + } } } - function update(value) { - - if (typeof value === 'undefined') { - value = input.property('value'); - } - - var e = d3.event; - - function render(data) { - - if (data.length && - document.activeElement === input.node()) show(); - else return hide(); - - var match; - - for (var i = 0; i < data.length; i++) { - if (data[i].value.toLowerCase().indexOf(value.toLowerCase()) === 0) { - match = data[i].value; - break; - } - } - - // backspace - if (e.keyCode === 8) { - prevValue = value; - prevCompletion = ''; - } else if (value && match && value !== prevValue + prevCompletion) { - prevValue = value; - prevCompletion = match.substr(value.length); - input.property('value', prevValue + prevCompletion); - input.node().setSelectionRange(value.length, value.length + prevCompletion.length); - } - - updateSize(); - - var options = container - .selectAll('a.combobox-option') - .data(data, function(d) { return d.value; }); - - options.enter().append('a') - .attr('class', 'combobox-option') - .text(function(d) { return d.value; }); - - options - .attr('title', function(d) { return d.title; }) - .classed('selected', function(d, i) { return i == idx; }) - .on('click', select) - .order(); - - options.exit() - .remove(); - } - - fetcher.apply(input, [value, render]); + function select(d, i) { + idx = i; + console.log('selected ' + idx); + render(); } - // select the choice given as d - function select(d) { + function accept(d) { + if (!shown) return; input .property('value', d.value) .trigger('change'); event.accept(d); hide(); } - - function mousedown() { - - if (shown) return hide(); - - input.node().focus(); - update(''); - - if (container.empty()) return; - - var entries = container.selectAll('a'), - height = container.node().scrollHeight / entries[0].length, - w = d3.select(window); - - function getIndex(m) { - return Math.floor((m[1] + container.node().scrollTop) / height); - } - - function withinBounds(m) { - var n = container.node(); - return m[0] >= 0 && m[0] < n.offsetWidth && - m[1] >= 0 && m[1] < n.offsetHeight; - } - - w.on('mousemove.typeahead', function() { - var m = d3.mouse(container.node()); - var within = withinBounds(m); - var n = getIndex(m); - entries.classed('selected', function(d, i) { return within && i === n; }); - }); - - w.on('mouseup.typeahead', function() { - var m = d3.mouse(container.node()); - if (withinBounds(m)) select(d3.select(entries[0][getIndex(m)]).datum()); - entries.classed('selected', false); - w.on('mouseup.typeahead', null); - w.on('mousemove.typeahead', null); - }); - } - - input - .on('blur.typeahead', blur) - .on('keydown.typeahead', keydown) - .on('keyup.typeahead', keyup) - .on('mousedown.typeahead', mousedown); }; combobox.fetcher = function(_) { diff --git a/test/index.html b/test/index.html index 2b468566d..a7ccfa536 100644 --- a/test/index.html +++ b/test/index.html @@ -188,6 +188,7 @@ + diff --git a/test/spec/lib/d3.combobox.js b/test/spec/lib/d3.combobox.js new file mode 100644 index 000000000..c39d1f89f --- /dev/null +++ b/test/spec/lib/d3.combobox.js @@ -0,0 +1,183 @@ +describe("d3.combobox", function() { + var body, content, input, combobox; + + var data = [ + {title: 'abbot', value: 'abbot'}, + {title: 'costello', value: 'costello'} + ]; + + function simulateKeypress(key) { + var keyCode = d3.keybinding.keyCodes[key], + value = input.property('value'), + start = input.property('selectionStart'), + finis = input.property('selectionEnd'); + + happen.keydown(input.node(), {keyCode: keyCode}); + + switch (key) { + case '⇥': + break; + + case '←': + start = finis = Math.max(0, start - 1); + input.node().setSelectionRange(start, finis); + break; + + case '→': + start = finis = Math.max(start + 1, value.length); + input.node().setSelectionRange(start, finis); + break; + + case '↑': + case '↓': + break; + + case '⌫': + value = value.substring(0, start - (start === finis ? 1 : 0)) + + value.substring(finis, value.length); + input.property('value', value); + happen.once(input.node(), {type: 'input'}); + break; + + case '⌦': + value = value.substring(0, start) + + value.substring(finis + (start === finis ? 1 : 0), value.length); + input.property('value', value); + happen.once(input.node(), {type: 'input'}); + break; + + default: + value = value.substring(0, start) + key + value.substring(finis, value.length); + input.property('value', value); + happen.once(input.node(), {type: 'input'}); + } + + happen.keyup(input.node(), {keyCode: keyCode}); + } + + beforeEach(function() { + body = d3.select('body'); + content = body.append('div'); + input = content.append('input'); + combobox = d3.combobox(); + }); + + afterEach(function() { + content.remove(); + body.selectAll('.combobox').remove(); + }); + + it("adds the combobox-input class", function() { + input.call(combobox); + expect(input).to.be.classed('combobox-input'); + }); + + it("creates entries for each datum", function() { + input.call(combobox.data(data)); + input.node().focus(); + expect(body.selectAll('.combobox-option').size()).to.equal(2); + }); + + it("filters entries to those matching the value", function() { + input.property('value', 'c').call(combobox.data(data)); + input.node().focus(); + expect(body.selectAll('.combobox-option').size()).to.equal(1); + expect(body.selectAll('.combobox-option').text()).to.equal('costello'); + }); + + it("is initially shown with no selection", function() { + input.call(combobox.data(data)); + input.node().focus(); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(0); + }); + + it("selects the first option matching the input", function() { + input.call(combobox.data(data)); + input.node().focus(); + simulateKeypress('c'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(1); + expect(body.selectAll('.combobox-option.selected').text()).to.equal('costello'); + }); + + it("selects the completed portion of the value", function() { + input.call(combobox.data(data)); + input.node().focus(); + simulateKeypress('c'); + expect(input.property('value')).to.equal('costello'); + expect(input.property('selectionStart')).to.equal(1); + expect(input.property('selectionEnd')).to.equal(8); + }); + + it("preserves the case of the input portion of the value", function() { + input.call(combobox.data(data)); + input.node().focus(); + simulateKeypress('C'); + expect(input.property('value')).to.equal('Costello'); + expect(input.property('selectionStart')).to.equal(1); + expect(input.property('selectionEnd')).to.equal(8); + }); + + it("does not select on ⇥", function() { + input.call(combobox.data(data)); + input.node().focus(); + simulateKeypress('c'); + simulateKeypress('⇥'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(0); + }); + + it("does not select when value is empty", function() { + input.call(combobox.data(data)); + input.node().focus(); + happen.once(input.node(), {type: 'input'}); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(0); + }); + + it("does not select when value is not a prefix of any suggestion", function() { + input.call(combobox.data(data)); + input.node().focus(); + simulateKeypress('c'); + simulateKeypress('a'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(0); + }); + + it("does not select or autocomplete after ⌫", function() { + input.call(combobox.data(data)); + input.node().focus(); + simulateKeypress('c'); + simulateKeypress('⌫'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(0); + expect(input.property('value')).to.equal('c'); + }); + + it("does not select or autocomplete after ⌦", function() { + input.call(combobox.data(data)); + input.node().focus(); + simulateKeypress('a'); + simulateKeypress('c'); + simulateKeypress('←'); + simulateKeypress('←'); + simulateKeypress('⌦'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(0); + expect(input.property('value')).to.equal('c'); + }); + + it("selects and autocompletes the next/prev suggestion on ↓/↑", function() { + input.call(combobox.data(data)); + input.node().focus(); + + simulateKeypress('↓'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(1); + expect(body.selectAll('.combobox-option.selected').text()).to.equal('abbot'); + expect(input.property('value')).to.equal('abbot'); + + simulateKeypress('↓'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(1); + expect(body.selectAll('.combobox-option.selected').text()).to.equal('costello'); + expect(input.property('value')).to.equal('costello'); + + simulateKeypress('↑'); + expect(body.selectAll('.combobox-option.selected').size()).to.equal(1); + expect(body.selectAll('.combobox-option.selected').text()).to.equal('abbot'); + expect(input.property('value')).to.equal('abbot'); + }); +});