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');
+ });
+});