mirror of
https://github.com/FoggedLens/iD.git
synced 2026-02-28 08:23:46 +00:00
Refining combobox behavior, with tests
This commit is contained in:
@@ -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(_) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
183
test/spec/lib/d3.combobox.js
Normal file
183
test/spec/lib/d3.combobox.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user