Refining combobox behavior, with tests

This commit is contained in:
John Firebaugh
2013-06-21 15:53:39 -07:00
parent c8da4851e1
commit aecb07a41a
3 changed files with 317 additions and 152 deletions

View File

@@ -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(_) {

View File

@@ -188,6 +188,7 @@
<script src="spec/spec_helpers.js"></script>
<!-- include spec files here... -->
<script src="spec/lib/d3.combobox.js"></script>
<script src="spec/lib/d3.keybinding.js"></script>
<script src="spec/lib/locale.js"></script>

View File

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