From 9261752068536b12b49b20992822c9a2f78edde4 Mon Sep 17 00:00:00 2001 From: Kushan Joshi <0o3ko0@gmail.com> Date: Sun, 3 Apr 2016 12:39:43 +0530 Subject: [PATCH 01/16] add multiselect preset --- Makefile | 1 + css/app.css | 52 +++++ data/presets.yaml | 29 +-- data/presets/fields.json | 48 +---- data/presets/fields/internet_access.json | 2 +- data/presets/fields/recycling/cans.json | 5 - data/presets/fields/recycling/clothes.json | 5 - data/presets/fields/recycling/glass.json | 5 - .../fields/recycling/glass_bottles.json | 5 - data/presets/fields/recycling/paper.json | 5 - data/presets/fields/recycling/plastic.json | 5 - .../fields/recycling/recycling_choices.json | 5 + data/presets/fields/recycling/type.json | 11 - data/presets/fields/sport_ice.json | 2 +- data/presets/presets.json | 8 +- data/presets/presets/amenity/recycling.json | 8 +- data/presets/schema/field.json | 1 + dist/locales/en.json | 26 +-- index.html | 1 + js/id/ui/preset/multiselect.js | 194 ++++++++++++++++++ js/lib/d3.combobox.js | 4 +- 21 files changed, 271 insertions(+), 151 deletions(-) delete mode 100644 data/presets/fields/recycling/cans.json delete mode 100644 data/presets/fields/recycling/clothes.json delete mode 100644 data/presets/fields/recycling/glass.json delete mode 100644 data/presets/fields/recycling/glass_bottles.json delete mode 100644 data/presets/fields/recycling/paper.json delete mode 100644 data/presets/fields/recycling/plastic.json create mode 100644 data/presets/fields/recycling/recycling_choices.json delete mode 100644 data/presets/fields/recycling/type.json create mode 100644 js/id/ui/preset/multiselect.js diff --git a/Makefile b/Makefile index edd31d1e9..adcf491a7 100644 --- a/Makefile +++ b/Makefile @@ -232,6 +232,7 @@ dist/iD.js: \ js/id/ui/preset/address.js \ js/id/ui/preset/check.js \ js/id/ui/preset/combo.js \ + js/id/ui/preset/multiselect.js \ js/id/ui/preset/cycleway.js \ js/id/ui/preset/input.js \ js/id/ui/preset/localized.js \ diff --git a/css/app.css b/css/app.css index c29f6429b..345e5af73 100644 --- a/css/app.css +++ b/css/app.css @@ -1193,6 +1193,58 @@ button.save.has-count .count::before { border-bottom-right-radius: 4px; } +/* preset form multiselect */ + +.form-field-multiselect { + border: 1px solid #cfcfcf; + border-top: 0px; + padding: 5px 0 5px 10px; + background: #fff; + display: block; + border-radius: 0 0 4px 4px; + overflow: hidden; +} + +.form-field-multiselect:focus { + border-bottom: 0px; +} + +.form-field-multiselect.active { + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; +} + +.form-field-multiselect li { + background-color: #eff2f7; + border: 1px solid #ccd5e3; + border-radius: 4px; + line-height: 25px; + display: inline-block; + padding: 2px 5px; + margin: 0 10px 5px 0; + height: 30px; +} + +.form-field-multiselect a { + font-family: Arial, Helvetica, sans-serif !important; + font-size: 16px !important; + line-height: 25px; + float: right; + margin: 1px 0 0 5px; + padding: 0; + cursor: pointer; + color: #a6b4ce; +} + +.form-field-multiselect input { + border: 0px; + width: 110px; +} + +.form-field-multiselect input:focus { + border-radius: 4px !important; +} + /* preset form cycleway */ .form-field-cycleway .preset-input-wrap li { diff --git a/data/presets.yaml b/data/presets.yaml index 8884e3cbf..c79e1c932 100644 --- a/data/presets.yaml +++ b/data/presets.yaml @@ -773,32 +773,9 @@ en: railway: # 'railway=*' label: Type - recycling/cans: - # 'recycling:cans=*' - label: Accepts Cans - recycling/clothes: - # 'recycling:clothes=*' - label: Accepts Clothes - recycling/glass: - # 'recycling:glass=*' - label: Accepts Glass - recycling/glass_bottles: - # 'recycling:glass_bottles=*' - label: Accepts Glass Bottles - recycling/paper: - # 'recycling:paper=*' - label: Accepts Paper - recycling/plastic: - # 'recycling:plastic=*' - label: Accepts Plastic - recycling/type: - # 'recycling_type=*' - label: Recycling Type - options: - # recycling_type=centre - centre: Recycling Center - # recycling_type=container - container: Container + recycling/recycling_choices: + # 'recycling=*' + label: Accepts ref: # 'ref=*' label: Reference diff --git a/data/presets/fields.json b/data/presets/fields.json index d2aec1869..1c7bb7395 100644 --- a/data/presets/fields.json +++ b/data/presets/fields.json @@ -653,7 +653,7 @@ }, "internet_access": { "key": "internet_access", - "type": "combo", + "type": "multiselect", "label": "Internet Access", "strings": { "options": { @@ -1020,46 +1020,10 @@ "type": "typeCombo", "label": "Type" }, - "recycling/cans": { - "key": "recycling:cans", - "type": "check", - "label": "Accepts Cans" - }, - "recycling/clothes": { - "key": "recycling:clothes", - "type": "check", - "label": "Accepts Clothes" - }, - "recycling/glass": { - "key": "recycling:glass", - "type": "check", - "label": "Accepts Glass" - }, - "recycling/glass_bottles": { - "key": "recycling:glass_bottles", - "type": "check", - "label": "Accepts Glass Bottles" - }, - "recycling/paper": { - "key": "recycling:paper", - "type": "check", - "label": "Accepts Paper" - }, - "recycling/plastic": { - "key": "recycling:plastic", - "type": "check", - "label": "Accepts Plastic" - }, - "recycling/type": { - "key": "recycling_type", - "type": "combo", - "label": "Recycling Type", - "strings": { - "options": { - "container": "Container", - "centre": "Recycling Center" - } - } + "recycling/recycling_choices": { + "key": "recycling", + "type": "multiselect", + "label": "Accepts" }, "ref": { "key": "ref", @@ -1270,7 +1234,7 @@ }, "sport_ice": { "key": "sport", - "type": "combo", + "type": "multiselect", "label": "Sport", "options": [ "skating", diff --git a/data/presets/fields/internet_access.json b/data/presets/fields/internet_access.json index a628dfd5f..ec5ca1f7c 100644 --- a/data/presets/fields/internet_access.json +++ b/data/presets/fields/internet_access.json @@ -1,6 +1,6 @@ { "key": "internet_access", - "type": "combo", + "type": "multiselect", "label": "Internet Access", "strings": { "options": { diff --git a/data/presets/fields/recycling/cans.json b/data/presets/fields/recycling/cans.json deleted file mode 100644 index 8829418c6..000000000 --- a/data/presets/fields/recycling/cans.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "key": "recycling:cans", - "type": "check", - "label": "Accepts Cans" -} diff --git a/data/presets/fields/recycling/clothes.json b/data/presets/fields/recycling/clothes.json deleted file mode 100644 index f90dfaac6..000000000 --- a/data/presets/fields/recycling/clothes.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "key": "recycling:clothes", - "type": "check", - "label": "Accepts Clothes" -} diff --git a/data/presets/fields/recycling/glass.json b/data/presets/fields/recycling/glass.json deleted file mode 100644 index df6b23073..000000000 --- a/data/presets/fields/recycling/glass.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "key": "recycling:glass", - "type": "check", - "label": "Accepts Glass" -} diff --git a/data/presets/fields/recycling/glass_bottles.json b/data/presets/fields/recycling/glass_bottles.json deleted file mode 100644 index ab4621f7b..000000000 --- a/data/presets/fields/recycling/glass_bottles.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "key": "recycling:glass_bottles", - "type": "check", - "label": "Accepts Glass Bottles" -} diff --git a/data/presets/fields/recycling/paper.json b/data/presets/fields/recycling/paper.json deleted file mode 100644 index 49e09c46e..000000000 --- a/data/presets/fields/recycling/paper.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "key": "recycling:paper", - "type": "check", - "label": "Accepts Paper" -} diff --git a/data/presets/fields/recycling/plastic.json b/data/presets/fields/recycling/plastic.json deleted file mode 100644 index 2bb43abd4..000000000 --- a/data/presets/fields/recycling/plastic.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "key": "recycling:plastic", - "type": "check", - "label": "Accepts Plastic" -} diff --git a/data/presets/fields/recycling/recycling_choices.json b/data/presets/fields/recycling/recycling_choices.json new file mode 100644 index 000000000..9500270e6 --- /dev/null +++ b/data/presets/fields/recycling/recycling_choices.json @@ -0,0 +1,5 @@ +{ + "key": "recycling", + "type": "multiselect", + "label": "Accepts" +} diff --git a/data/presets/fields/recycling/type.json b/data/presets/fields/recycling/type.json deleted file mode 100644 index e6d44574b..000000000 --- a/data/presets/fields/recycling/type.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "key": "recycling_type", - "type": "combo", - "label": "Recycling Type", - "strings": { - "options": { - "container": "Container", - "centre": "Recycling Center" - } - } -} diff --git a/data/presets/fields/sport_ice.json b/data/presets/fields/sport_ice.json index 7e5133bf9..1d42d5fb8 100644 --- a/data/presets/fields/sport_ice.json +++ b/data/presets/fields/sport_ice.json @@ -1,6 +1,6 @@ { "key": "sport", - "type": "combo", + "type": "multiselect", "label": "Sport", "options": [ "skating", diff --git a/data/presets/presets.json b/data/presets/presets.json index 88e740558..3b1318b06 100644 --- a/data/presets/presets.json +++ b/data/presets/presets.json @@ -1581,13 +1581,7 @@ "fields": [ "operator", "address", - "recycling/type", - "recycling/cans", - "recycling/glass_bottles", - "recycling/paper", - "recycling/glass", - "recycling/plastic", - "recycling/clothes" + "recycling/recycling_choices" ], "geometry": [ "point", diff --git a/data/presets/presets/amenity/recycling.json b/data/presets/presets/amenity/recycling.json index 5651c5774..23865fe5e 100644 --- a/data/presets/presets/amenity/recycling.json +++ b/data/presets/presets/amenity/recycling.json @@ -3,13 +3,7 @@ "fields": [ "operator", "address", - "recycling/type", - "recycling/cans", - "recycling/glass_bottles", - "recycling/paper", - "recycling/glass", - "recycling/plastic", - "recycling/clothes" + "recycling/recycling_choices" ], "geometry": [ "point", diff --git a/data/presets/schema/field.json b/data/presets/schema/field.json index e43366ce5..89ac31459 100644 --- a/data/presets/schema/field.json +++ b/data/presets/schema/field.json @@ -56,6 +56,7 @@ "defaultcheck", "text", "maxspeed", + "multiselect", "number", "tel", "email", diff --git a/dist/locales/en.json b/dist/locales/en.json index 2a5423794..8f41248e7 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -1277,30 +1277,8 @@ "railway": { "label": "Type" }, - "recycling/cans": { - "label": "Accepts Cans" - }, - "recycling/clothes": { - "label": "Accepts Clothes" - }, - "recycling/glass": { - "label": "Accepts Glass" - }, - "recycling/glass_bottles": { - "label": "Accepts Glass Bottles" - }, - "recycling/paper": { - "label": "Accepts Paper" - }, - "recycling/plastic": { - "label": "Accepts Plastic" - }, - "recycling/type": { - "label": "Recycling Type", - "options": { - "container": "Container", - "centre": "Recycling Center" - } + "recycling/recycling_choices": { + "label": "Accepts" }, "ref": { "label": "Reference" diff --git a/index.html b/index.html index 80d184a2f..fc67bc970 100644 --- a/index.html +++ b/index.html @@ -129,6 +129,7 @@ + diff --git a/js/id/ui/preset/multiselect.js b/js/id/ui/preset/multiselect.js new file mode 100644 index 000000000..ad54d2230 --- /dev/null +++ b/js/id/ui/preset/multiselect.js @@ -0,0 +1,194 @@ +iD.ui.preset.multiselect = function(field, context) { + var dispatch = d3.dispatch('init', 'change'), + optstrings = field.strings && field.strings.options, + optarray = field.options, + strings = {}, + multiselectContainer, + combobox, + comboboxData, + input, + isInitialized; + + field.key += ':'; + + function getOptStringKey(val) { + if (optstrings) { + var match = _.find(strings, function(o) { + return o.value === val; + }); + return match && match.key; + } + } + + function getOptStringVal(key) { + if (optstrings) { + var match = _.find(strings, function(o) { + return o.key === key; + }); + return match && match.value; + } + } + + function objectDifference(a, b) { + var bObj = {}; + b.forEach(function(obj){ + bObj[obj.key] = obj; + }); + // Return all elements in a, unless in b + return a.filter(function(obj){ + return !(obj.key in bObj); + }); + } + + function multiselect(selection) { + isInitialized = false; + combobox = d3.combobox(); + + multiselectContainer = selection.selectAll('ul').data([0]); + + multiselectContainer.enter() + .append('ul') + .on('click', function() { + window.setTimeout(function(){input.node().focus();}, 100); + }) + .attr('class', 'form-field-multiselect'); + + input = multiselectContainer.selectAll('input') + .data([0]); + + var enter = input.enter() + .append('input') + .attr('type', 'text') + .attr('id', 'preset-input-' + field.id); + + if (optstrings) { enter.attr('readonly', 'readonly'); } + + input + .call(function() {combobox(input, selection);}) + .on('change', change) + .on('blur', change) + .on('focus', function() {multiselectContainer.classed('active', true);}) + .each(function() { + if (optstrings) { + strings = Object.keys(optstrings).map(function(k) { + return { + key: k, + value: field.t('options.' + k, { 'default': optstrings[k] }) + }; + }); + dispatch.init(); + isInitialized = true; + } else if (optarray) { + strings = optarray.map(function(k) {return {key: k, value: k};}); + dispatch.init(); + isInitialized = true; + } else if (context.taginfo()) { + context.taginfo().keys({query: field.key}, function(err, data) { + if (!err) { + strings = data.map(function(k) { + var d = k.value.replace(field.key, ''); + return { + key: d, + value: d + }; + }); + dispatch.init(); + isInitialized = true; + } + }); + } + }); + } + + function updateStrings(tagsData) { + comboboxData = objectDifference(strings, tagsData); + combobox.data(comboboxData.map(comboValues)); + input.attr('placeholder', field.placeholder() || + ( 'Type here')); + } + + + function update(data) { + var chips = multiselectContainer.selectAll('.chips').data(data); + + var chip = chips.enter() + .insert('li', 'input') + .attr('class', 'chips'); + + chip.append('span'); + chip.append('a'); + + chips.select('span').text(function(d) {return d.value;}); + + chips.select('a') + .on('click', removeKey) + .attr('class', 'remove') + .text('×'); + + chips.exit().remove(); + } + + function comboValues(d) { + return { + value: d.value, + title: d.value + }; + } + + function change() { + multiselectContainer.classed('active', false); + var key = getOptStringKey(input.value()) || input.value(); + if (key && key !== '') { + var t = {}; + t[field.key + key] = 'yes'; + input.value(''); + dispatch.change(t); + } + } + + function removeKey(d) { + d3.event.stopPropagation(); + var t = {}; + t[field.key + d.key] = undefined; + dispatch.change(t); + } + + multiselect.tags = function(tags) { + var tagsData = []; + Object.keys(tags).forEach(function(d) { + if (d.indexOf(field.key) > -1 && tags[d] === 'yes') { + var datum = d.replace(field.key, ''); + + if (!optstrings) { + return tagsData.push({ + key: datum, + value: datum + }); + } + // discards any pair not found in optstrings + if (optstrings && getOptStringVal(datum)) { + return tagsData.push({ + key: datum, + value: getOptStringVal(datum) + }); + } + } + }); + + update(tagsData); + + if (isInitialized) { + updateStrings(tagsData); + } else { + dispatch.on('init', function () { + updateStrings(tagsData); + }); + } + }; + + multiselect.focus = function() { + input.node().focus(); + }; + + return d3.rebind(multiselect, dispatch, 'on'); +}; diff --git a/js/lib/d3.combobox.js b/js/lib/d3.combobox.js index 083b1a970..bb605f582 100644 --- a/js/lib/d3.combobox.js +++ b/js/lib/d3.combobox.js @@ -13,7 +13,7 @@ d3.combobox = function() { })); }; - var combobox = function(input) { + var combobox = function(input, customBoundingRect) { var idx = -1, container = d3.select(document.body) .selectAll('div.combobox') @@ -222,7 +222,7 @@ d3.combobox = function() { options.exit() .remove(); - var rect = input.node().getBoundingClientRect(); + var rect = customBoundingRect ? customBoundingRect.node().getBoundingClientRect() : input.node().getBoundingClientRect(); container.style({ 'left': rect.left + 'px', From 1a9c111d782495063a9911211d2f525057a973e0 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 22 Apr 2016 21:22:24 -0400 Subject: [PATCH 02/16] Set field.keys so that delete/undo buttons work For multiselect fields, field.key should have a ':' on the end of it - we use as the prefix, not the real key field.keys should contain the array of real keys being modified see https://github.com/openstreetmap/iD/blob/master/js/id/ui/preset.js#L17-L45 The address field preset works like this also.. It contains an array of key.fields for all the subfields that might be set. For multiselect fields, we don't know ahead of time what all the subfields are, so we append to the list in `change()` and rebuild list in `tags()` --- data/presets.yaml | 2 +- data/presets/fields.json | 2 +- .../fields/recycling/recycling_choices.json | 2 +- js/id/ui/preset/multiselect.js | 28 +++++++++++-------- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/data/presets.yaml b/data/presets.yaml index c79e1c932..572c41e69 100644 --- a/data/presets.yaml +++ b/data/presets.yaml @@ -774,7 +774,7 @@ en: # 'railway=*' label: Type recycling/recycling_choices: - # 'recycling=*' + # 'recycling:=*' label: Accepts ref: # 'ref=*' diff --git a/data/presets/fields.json b/data/presets/fields.json index 1c7bb7395..0a88d5232 100644 --- a/data/presets/fields.json +++ b/data/presets/fields.json @@ -1021,7 +1021,7 @@ "label": "Type" }, "recycling/recycling_choices": { - "key": "recycling", + "key": "recycling:", "type": "multiselect", "label": "Accepts" }, diff --git a/data/presets/fields/recycling/recycling_choices.json b/data/presets/fields/recycling/recycling_choices.json index 9500270e6..57398e714 100644 --- a/data/presets/fields/recycling/recycling_choices.json +++ b/data/presets/fields/recycling/recycling_choices.json @@ -1,5 +1,5 @@ { - "key": "recycling", + "key": "recycling:", "type": "multiselect", "label": "Accepts" } diff --git a/js/id/ui/preset/multiselect.js b/js/id/ui/preset/multiselect.js index ad54d2230..27424b867 100644 --- a/js/id/ui/preset/multiselect.js +++ b/js/id/ui/preset/multiselect.js @@ -9,7 +9,10 @@ iD.ui.preset.multiselect = function(field, context) { input, isInitialized; - field.key += ':'; + // ensure field.key ends with a ':' + if (field.key.match(/.*:$/) === null) { + field.key += ':'; + } function getOptStringKey(val) { if (optstrings) { @@ -35,7 +38,7 @@ iD.ui.preset.multiselect = function(field, context) { bObj[obj.key] = obj; }); // Return all elements in a, unless in b - return a.filter(function(obj){ + return a.filter(function(obj) { return !(obj.key in bObj); }); } @@ -71,10 +74,10 @@ iD.ui.preset.multiselect = function(field, context) { .each(function() { if (optstrings) { strings = Object.keys(optstrings).map(function(k) { - return { - key: k, - value: field.t('options.' + k, { 'default': optstrings[k] }) - }; + return { + key: k, + value: field.t('options.' + k, { 'default': optstrings[k] }) + }; }); dispatch.init(); isInitialized = true; @@ -111,12 +114,12 @@ iD.ui.preset.multiselect = function(field, context) { function update(data) { var chips = multiselectContainer.selectAll('.chips').data(data); - var chip = chips.enter() + var enter = chips.enter() .insert('li', 'input') .attr('class', 'chips'); - chip.append('span'); - chip.append('a'); + enter.append('span'); + enter.append('a'); chips.select('span').text(function(d) {return d.value;}); @@ -130,8 +133,8 @@ iD.ui.preset.multiselect = function(field, context) { function comboValues(d) { return { - value: d.value, - title: d.value + value: d.value, + title: d.value }; } @@ -142,6 +145,7 @@ iD.ui.preset.multiselect = function(field, context) { var t = {}; t[field.key + key] = 'yes'; input.value(''); + field.keys.push(field.key + key); dispatch.change(t); } } @@ -175,6 +179,8 @@ iD.ui.preset.multiselect = function(field, context) { } }); + field.keys = _.map(_.pluck(tagsData, 'key'), function(v) { return field.key + v; }); + update(tagsData); if (isInitialized) { From 30df2c394f4df38b0aecf846229e692cb596f1de Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 22 Apr 2016 21:59:43 -0400 Subject: [PATCH 03/16] set minItems default in d3.combobox to 1 --- js/lib/d3.combobox.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/lib/d3.combobox.js b/js/lib/d3.combobox.js index 93f6b052f..46c8e649b 100644 --- a/js/lib/d3.combobox.js +++ b/js/lib/d3.combobox.js @@ -2,7 +2,7 @@ d3.combobox = function() { var event = d3.dispatch('accept'), data = [], suggestions = [], - minItems = 2, + minItems = 1, caseSensitive = false; var fetcher = function(val, cb) { From 1b73ce9339d8ab73b6606f1bc02dffa54ab34c74 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 22 Apr 2016 22:20:47 -0400 Subject: [PATCH 04/16] Set minItems 1 only for multiselect field --- js/id/ui/preset/multiselect.js | 2 +- js/lib/d3.combobox.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/id/ui/preset/multiselect.js b/js/id/ui/preset/multiselect.js index 27424b867..de1e21117 100644 --- a/js/id/ui/preset/multiselect.js +++ b/js/id/ui/preset/multiselect.js @@ -45,7 +45,7 @@ iD.ui.preset.multiselect = function(field, context) { function multiselect(selection) { isInitialized = false; - combobox = d3.combobox(); + combobox = d3.combobox().minItems(1); multiselectContainer = selection.selectAll('ul').data([0]); diff --git a/js/lib/d3.combobox.js b/js/lib/d3.combobox.js index 46c8e649b..93f6b052f 100644 --- a/js/lib/d3.combobox.js +++ b/js/lib/d3.combobox.js @@ -2,7 +2,7 @@ d3.combobox = function() { var event = d3.dispatch('accept'), data = [], suggestions = [], - minItems = 1, + minItems = 2, caseSensitive = false; var fetcher = function(val, cb) { From e24acfab77a1ca2eed86a38e6a73581a2e780f50 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 22 Apr 2016 22:32:41 -0400 Subject: [PATCH 05/16] Change Multiselect input placeholder text to translateable "Add" --- data/core.yaml | 1 + dist/locales/en.json | 1 + js/id/ui/preset/multiselect.js | 4 +--- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/core.yaml b/data/core.yaml index be85da56e..97c852e03 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -261,6 +261,7 @@ en: check: "yes": "Yes" "no": "No" + add: Add none: None node: Node way: Way diff --git a/dist/locales/en.json b/dist/locales/en.json index f12b6bb4e..cdca08f78 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -316,6 +316,7 @@ "yes": "Yes", "no": "No" }, + "add": "Add", "none": "None", "node": "Node", "way": "Way", diff --git a/js/id/ui/preset/multiselect.js b/js/id/ui/preset/multiselect.js index de1e21117..400e5efa2 100644 --- a/js/id/ui/preset/multiselect.js +++ b/js/id/ui/preset/multiselect.js @@ -106,11 +106,9 @@ iD.ui.preset.multiselect = function(field, context) { function updateStrings(tagsData) { comboboxData = objectDifference(strings, tagsData); combobox.data(comboboxData.map(comboValues)); - input.attr('placeholder', field.placeholder() || - ( 'Type here')); + input.attr('placeholder', (field.placeholder() || t('inspector.add')) + '…'); } - function update(data) { var chips = multiselectContainer.selectAll('.chips').data(data); From 84edbee26a9a7dba91d75133b027564021b08054 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 26 Apr 2016 16:59:45 -0400 Subject: [PATCH 06/16] Lower popularity thresholds for taginfo lookups https://github.com/openstreetmap/iD/pull/3080#issuecomment-214881846 --- js/id/services/taginfo.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/id/services/taginfo.js b/js/id/services/taginfo.js index 4bd7c8750..6aaf81e97 100644 --- a/js/id/services/taginfo.js +++ b/js/id/services/taginfo.js @@ -37,11 +37,11 @@ iD.services.taginfo = function() { function popularKeys(parameters) { var pop_field = 'count_all'; if (parameters.filter) pop_field = 'count_' + parameters.filter; - return function(d) { return parseFloat(d[pop_field]) > 5000 || d.in_wiki; }; + return function(d) { return parseFloat(d[pop_field]) > 2500 || d.in_wiki; }; } function popularValues() { - return function(d) { return parseFloat(d.fraction) > 0.01 || d.in_wiki; }; + return function(d) { return parseFloat(d.fraction) > 0.0 || d.in_wiki; }; } function valKey(d) { From 25862f35e7cf27171e9708674187ee57b2ce4cac Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 26 Apr 2016 19:37:15 -0400 Subject: [PATCH 07/16] Add multikeys taginfo function, filter out values with ';' --- js/id/services/taginfo.js | 38 +++++++++++++++++++++----- js/id/ui/preset/multiselect.js | 2 +- test/spec/services/taginfo.js | 49 +++++++++++++++++++++++++++++----- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/js/id/services/taginfo.js b/js/id/services/taginfo.js index 6aaf81e97..0af481616 100644 --- a/js/id/services/taginfo.js +++ b/js/id/services/taginfo.js @@ -34,14 +34,25 @@ iD.services.taginfo = function() { return _.omit(parameters, 'geometry', 'debounce'); } - function popularKeys(parameters) { + function filterKeys(parameters) { var pop_field = 'count_all'; if (parameters.filter) pop_field = 'count_' + parameters.filter; - return function(d) { return parseFloat(d[pop_field]) > 2500 || d.in_wiki; }; + return function(d) { + return parseFloat(d[pop_field]) > 2500 || d.in_wiki; + }; } - function popularValues() { - return function(d) { return parseFloat(d.fraction) > 0.0 || d.in_wiki; }; + function filterMultikeys() { + return function(d) { + return (d.key.match(/:/g) || []).length === 1; // exactly one ':' + }; + } + + function filterValues() { + return function(d) { + return d.value.match(/;/g) === null && // exclude values with ';' + (parseFloat(d.fraction) > 0.0 || d.in_wiki); + }; } function valKey(d) { @@ -95,7 +106,22 @@ iD.services.taginfo = function() { page: 1 }, parameters)), debounce, function(err, d) { if (err) return callback(err); - callback(null, d.data.filter(popularKeys(parameters)).sort(sortKeys).map(valKey)); + callback(null, d.data.filter(filterKeys(parameters)).sort(sortKeys).map(valKey)); + }); + }; + + taginfo.multikeys = function(parameters, callback) { + var debounce = parameters.debounce; + parameters = clean(setSort(parameters)); + request(endpoint + 'keys/all?' + + iD.util.qsString(_.extend({ + rp: 25, + sortname: 'count_all', + sortorder: 'desc', + page: 1 + }, parameters)), debounce, function(err, d) { + if (err) return callback(err); + callback(null, d.data.filter(filterMultikeys(parameters)).map(valKey)); }); }; @@ -110,7 +136,7 @@ iD.services.taginfo = function() { page: 1 }, parameters)), debounce, function(err, d) { if (err) return callback(err); - callback(null, d.data.filter(popularValues()).map(valKeyDescription), parameters); + callback(null, d.data.filter(filterValues()).map(valKeyDescription), parameters); }); }; diff --git a/js/id/ui/preset/multiselect.js b/js/id/ui/preset/multiselect.js index 400e5efa2..c0d5db19f 100644 --- a/js/id/ui/preset/multiselect.js +++ b/js/id/ui/preset/multiselect.js @@ -86,7 +86,7 @@ iD.ui.preset.multiselect = function(field, context) { dispatch.init(); isInitialized = true; } else if (context.taginfo()) { - context.taginfo().keys({query: field.key}, function(err, data) { + context.taginfo().multikeys({query: field.key}, function(err, data) { if (!err) { strings = data.map(function(k) { var d = k.value.replace(field.key, ''); diff --git a/test/spec/services/taginfo.js b/test/spec/services/taginfo.js index 76929aaee..b595e13c7 100644 --- a/test/spec/services/taginfo.js +++ b/test/spec/services/taginfo.js @@ -44,7 +44,6 @@ describe("iD.services.taginfo", function() { it("filters only popular keys with an entity type filter", function() { var callback = sinon.spy(); - taginfo.keys({query: "amen", filter: "nodes"}, callback); server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/keys/all"), @@ -58,7 +57,6 @@ describe("iD.services.taginfo", function() { it("sorts keys with ':' below keys without ':'", function() { var callback = sinon.spy(); - taginfo.keys({query: "ref"}, callback); server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/keys/all"), @@ -71,10 +69,38 @@ describe("iD.services.taginfo", function() { }); }); + describe("#multikeys", function() { + it("calls the given callback with the results of the multikeys query", function() { + var callback = sinon.spy(); + taginfo.multikeys({query: "recycling:"}, callback); + + server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/keys/all"), + [200, { "Content-Type": "application/json" }, + '{"data":[{"count_all":69593,"key":"recycling:glass","count_all_fraction":0.0}]}']); + server.respond(); + + expect(query(server.requests[0].url)).to.eql( + {query: "recycling:", page: "1", rp: "25", sortname: "count_all", sortorder: "desc"}); + expect(callback).to.have.been.calledWith(null, [{"title":"recycling:glass", "value":"recycling:glass"}]); + }); + + it("excludes multikeys with extra colons", function() { + var callback = sinon.spy(); + taginfo.multikeys({query: "recycling:"}, callback); + + server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/keys/all"), + [200, { "Content-Type": "application/json" }, + '{"data":[{"count_all":69593,"key":"recycling:glass","count_all_fraction":0.0},\ + {"count_all":22,"key":"recycling:glass:color","count_all_fraction":0.0}]}']); + server.respond(); + + expect(callback).to.have.been.calledWith(null, [{"title":"recycling:glass", "value":"recycling:glass"}]); + }); + }); + describe("#values", function() { it("calls the given callback with the results of the values query", function() { var callback = sinon.spy(); - taginfo.values({key: "amenity", query: "par"}, callback); server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/key/values"), @@ -89,13 +115,25 @@ describe("iD.services.taginfo", function() { it("filters popular values", function() { var callback = sinon.spy(); - taginfo.values({key: "amenity", query: "par"}, callback); server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/key/values"), [200, { "Content-Type": "application/json" }, '{"data":[{"value":"parking","description":"A place for parking cars", "fraction":1.0},\ - {"value":"party","description":"A place for partying", "fraction":0.0}]}']); + {"value":"party","description":"A place for partying", "fraction":0.0}]}']); + server.respond(); + + expect(callback).to.have.been.calledWith(null, [{"value":"parking","title":"A place for parking cars"}]); + }); + + it("excludes values with semicolons", function() { + var callback = sinon.spy(); + taginfo.values({key: "amenity", query: "par"}, callback); + + server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/key/values"), + [200, { "Content-Type": "application/json" }, + '{"data":[{"value":"parking","description":"A place for parking cars", "fraction":1.0},\ + {"value":"parking;partying","description":"A place for parking cars *and* partying", "fraction":1.0}]}']); server.respond(); expect(callback).to.have.been.calledWith(null, [{"value":"parking","title":"A place for parking cars"}]); @@ -105,7 +143,6 @@ describe("iD.services.taginfo", function() { describe("#docs", function() { it("calls the given callback with the results of the docs query", function() { var callback = sinon.spy(); - taginfo.docs({key: "amenity", value: "parking"}, callback); server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/tag/wiki_page"), From b9eeaa34dd2960a937b8f23102ae82923cde8889 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Wed, 27 Apr 2016 22:22:56 -0400 Subject: [PATCH 08/16] Add typeahead behavior to combo field (closes #3089) --- js/id/ui/preset/combo.js | 132 +++++++++++++++++++++++---------------- 1 file changed, 79 insertions(+), 53 deletions(-) diff --git a/js/id/ui/preset/combo.js b/js/id/ui/preset/combo.js index 3561e7352..66b12c7bc 100644 --- a/js/id/ui/preset/combo.js +++ b/js/id/ui/preset/combo.js @@ -5,7 +5,8 @@ iD.ui.preset.typeCombo = function(field, context) { optarray = field.options, snake_case = (field.snake_case || (field.snake_case === undefined)), strings = {}, - input; + input, + entity; function snake(s) { return s.replace(/\s+/g, '_'); @@ -27,65 +28,68 @@ iD.ui.preset.typeCombo = function(field, context) { }); } - function combo(selection) { - var combobox = d3.combobox(); + function initCombo(selection) { + var d; - input = selection.selectAll('input') - .data([0]); - - var enter = input.enter() - .append('input') - .attr('type', 'text') - .attr('id', 'preset-input-' + field.id); - - if (optstrings) { enter.attr('readonly', 'readonly'); } - - input - .call(combobox) - .on('change', change) - .on('blur', change) - .each(function() { - if (optstrings) { - _.each(optstrings, function(v, k) { - strings[k] = field.t('options.' + k, { 'default': v }); - }); - stringsLoaded(); - } else if (optarray) { - _.each(optarray, function(k) { - strings[k] = (snake_case ? unsnake(k) : k); - }); - stringsLoaded(); - } else if (context.taginfo()) { - context.taginfo().values({key: field.key}, function(err, data) { - if (!err) { - _.each(_.pluck(data, 'value'), function(k) { - strings[k] = (snake_case ? unsnake(k) : k); - }); - stringsLoaded(); - } - }); - } + if (optstrings) { + selection.attr('readonly', 'readonly'); + d = Object.keys(optstrings).map(function(k) { + var v = field.t('options.' + k, { 'default': optstrings[k] }); + return { + key: k, + value: v, + title: v + }; }); + selection.call(d3.combobox().data(d)); + setPlaceholders(d); - function stringsLoaded() { - var keys = _.keys(strings), - strs = [], - placeholders; + } else if (optarray) { + d = optarray.map(function(k) { + var v = snake_case ? unsnake(k) : k; + return { + key: k, + value: v, + title: v + }; + }); + selection.call(d3.combobox().data(d)); + setPlaceholders(d); - combobox.data(keys.map(function(k) { - var s = strings[k], - o = {}; - o.title = o.value = s; - if (s.length < 20) { strs.push(s); } - return o; - })); - - placeholders = strs.length > 1 ? strs : keys; - input.attr('placeholder', field.placeholder() || - (placeholders.slice(0, 3).join(', ') + '...')); + } else if (context.taginfo()) { + selection.call(d3.combobox().fetcher(taginfoValues)); + taginfoValues('', setPlaceholders); } } + function taginfoValues(q, callback) { + context.taginfo().values({ + debounce: true, + key: field.key, + geometry: context.geometry(entity.id), + query: q + }, function(err, data) { + if (err) return; + var d = _.pluck(data, 'value').map(function(k) { + var v = snake_case ? unsnake(k) : k; + return { + key: k, + value: v, + title: v + }; + }); + callback(d); + }); + } + + function setPlaceholders(d) { + var vals = _.pluck(d, 'value').filter(function(s) { return s.length < 20; }), + placeholders = vals.length > 1 ? vals : _.pluck(d, 'key'); + + input.attr('placeholder', field.placeholder() || + (placeholders.slice(0, 3).join(', ') + '…')); + } + function change() { var value = optString() || clean(input.value()); @@ -101,6 +105,22 @@ iD.ui.preset.typeCombo = function(field, context) { dispatch.change(t); } + + function combo(selection) { + input = selection.selectAll('input') + .data([0]); + + input.enter() + .append('input') + .attr('type', 'text') + .attr('id', 'preset-input-' + field.id) + .call(initCombo); + + input + .on('change', change) + .on('blur', change); + } + combo.tags = function(tags) { var key = tags[field.key], optstring = optString(), @@ -119,5 +139,11 @@ iD.ui.preset.typeCombo = function(field, context) { input.node().focus(); }; + combo.entity = function(_) { + if (!arguments.length) return entity; + entity = _; + return combo; + }; + return d3.rebind(combo, dispatch, 'on'); }; From 03bb9162870e562fc6481b55b1c09bb13eb36086 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 28 Apr 2016 16:59:12 -0400 Subject: [PATCH 09/16] Adjust taginfo values filtering, update tests --- js/id/services/taginfo.js | 20 +++++++++------- test/spec/services/taginfo.js | 45 +++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/js/id/services/taginfo.js b/js/id/services/taginfo.js index 0af481616..76148c014 100644 --- a/js/id/services/taginfo.js +++ b/js/id/services/taginfo.js @@ -34,11 +34,10 @@ iD.services.taginfo = function() { return _.omit(parameters, 'geometry', 'debounce'); } - function filterKeys(parameters) { - var pop_field = 'count_all'; - if (parameters.filter) pop_field = 'count_' + parameters.filter; + function filterKeys(type) { + var count_type = type ? 'count_' + type : 'count_all'; return function(d) { - return parseFloat(d[pop_field]) > 2500 || d.in_wiki; + return parseFloat(d[count_type]) > 2500 || d.in_wiki; }; } @@ -50,8 +49,8 @@ iD.services.taginfo = function() { function filterValues() { return function(d) { - return d.value.match(/;/g) === null && // exclude values with ';' - (parseFloat(d.fraction) > 0.0 || d.in_wiki); + if (d.value.match(/[A-Z*;,]/) !== null) return false; // exclude some punctuation, uppercase letters + return parseFloat(d.fraction) > 0.0 || d.in_wiki; }; } @@ -106,7 +105,8 @@ iD.services.taginfo = function() { page: 1 }, parameters)), debounce, function(err, d) { if (err) return callback(err); - callback(null, d.data.filter(filterKeys(parameters)).sort(sortKeys).map(valKey)); + var f = filterKeys(parameters.filter); + callback(null, d.data.filter(f).sort(sortKeys).map(valKey)); }); }; @@ -121,7 +121,8 @@ iD.services.taginfo = function() { page: 1 }, parameters)), debounce, function(err, d) { if (err) return callback(err); - callback(null, d.data.filter(filterMultikeys(parameters)).map(valKey)); + var f = filterMultikeys(); + callback(null, d.data.filter(f).map(valKey)); }); }; @@ -136,7 +137,8 @@ iD.services.taginfo = function() { page: 1 }, parameters)), debounce, function(err, d) { if (err) return callback(err); - callback(null, d.data.filter(filterValues()).map(valKeyDescription), parameters); + var f = filterValues(); + callback(null, d.data.filter(f).map(valKeyDescription)); }); }; diff --git a/test/spec/services/taginfo.js b/test/spec/services/taginfo.js index b595e13c7..55c938424 100644 --- a/test/spec/services/taginfo.js +++ b/test/spec/services/taginfo.js @@ -29,7 +29,7 @@ describe("iD.services.taginfo", function() { expect(callback).to.have.been.calledWith(null, [{"title":"amenity", "value":"amenity"}]); }); - it("filters only popular keys", function() { + it("includes popular keys", function() { var callback = sinon.spy(); taginfo.keys({query: "amen"}, callback); @@ -42,7 +42,7 @@ describe("iD.services.taginfo", function() { expect(callback).to.have.been.calledWith(null, [{"title":"amenity", "value":"amenity"}]); }); - it("filters only popular keys with an entity type filter", function() { + it("includes popular keys with an entity type filter", function() { var callback = sinon.spy(); taginfo.keys({query: "amen", filter: "nodes"}, callback); @@ -55,6 +55,22 @@ describe("iD.services.taginfo", function() { expect(callback).to.have.been.calledWith(null, [{"title":"amenity", "value":"amenity"}]); }); + it("includes unpopular keys with a wiki page", function() { + var callback = sinon.spy(); + taginfo.keys({query: "amen"}, callback); + + server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/keys/all"), + [200, { "Content-Type": "application/json" }, + '{"data":[{"count_all":5190337,"key":"amenity","count_all_fraction":1.0, "count_nodes_fraction":1.0},\ + {"count_all":1,"key":"amenityother","count_all_fraction":0.0, "count_nodes_fraction":0.0, "in_wiki": true}]}']); + server.respond(); + + expect(callback).to.have.been.calledWith(null, [ + {"title":"amenity", "value":"amenity"} + {"title":"amenityother", "value":"amenityother"} + ]); + }); + it("sorts keys with ':' below keys without ':'", function() { var callback = sinon.spy(); taginfo.keys({query: "ref"}, callback); @@ -113,7 +129,7 @@ describe("iD.services.taginfo", function() { expect(callback).to.have.been.calledWith(null, [{"value":"parking","title":"A place for parking cars"}]); }); - it("filters popular values", function() { + it("includes popular values", function() { var callback = sinon.spy(); taginfo.values({key: "amenity", query: "par"}, callback); @@ -126,14 +142,33 @@ describe("iD.services.taginfo", function() { expect(callback).to.have.been.calledWith(null, [{"value":"parking","title":"A place for parking cars"}]); }); - it("excludes values with semicolons", function() { + it("includes unpopular values with a wiki page", function() { var callback = sinon.spy(); taginfo.values({key: "amenity", query: "par"}, callback); server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/key/values"), [200, { "Content-Type": "application/json" }, '{"data":[{"value":"parking","description":"A place for parking cars", "fraction":1.0},\ - {"value":"parking;partying","description":"A place for parking cars *and* partying", "fraction":1.0}]}']); + {"value":"party","description":"A place for partying", "fraction":0.0, "in_wiki": true}]}']); + server.respond(); + + expect(callback).to.have.been.calledWith(null, [ + {"value":"parking","title":"A place for parking cars"}, + {"value":"party","title":"A place for partying"} + ]); + }); + + it("excludes values with capital letters and some punctuation", function() { + var callback = sinon.spy(); + taginfo.values({key: "amenity", query: "par"}, callback); + + server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/key/values"), + [200, { "Content-Type": "application/json" }, + '{"data":[{"value":"parking","description":"A place for parking cars", "fraction":0.2},\ + {"value":"PArking","description":"A common mispelling", "fraction":0.2},\ + {"value":"parking;partying","description":"A place for parking cars *and* partying", "fraction":0.2},\ + {"value":"parking, partying","description":"A place for parking cars *and* partying", "fraction":0.2},\ + {"value":"*","description":"", "fraction":0.2}]}']); server.respond(); expect(callback).to.have.been.calledWith(null, [{"value":"parking","title":"A place for parking cars"}]); From 5752207c91ed579aecd3184956bce520c128d3dd Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 29 Apr 2016 17:02:53 -0400 Subject: [PATCH 10/16] Refactor combo.js and move @kepta's multiselect code into there. --- data/presets/schema/field.json | 2 +- js/id/ui/preset/combo.js | 244 ++++++++++++++++++++++++++------- js/lib/d3.combobox.js | 5 +- 3 files changed, 199 insertions(+), 52 deletions(-) diff --git a/data/presets/schema/field.json b/data/presets/schema/field.json index 89ac31459..7b73733ee 100644 --- a/data/presets/schema/field.json +++ b/data/presets/schema/field.json @@ -56,7 +56,7 @@ "defaultcheck", "text", "maxspeed", - "multiselect", + "multiCombo", "number", "tel", "email", diff --git a/js/id/ui/preset/combo.js b/js/id/ui/preset/combo.js index 66b12c7bc..e29ab05f8 100644 --- a/js/id/ui/preset/combo.js +++ b/js/id/ui/preset/combo.js @@ -1,13 +1,24 @@ iD.ui.preset.combo = -iD.ui.preset.typeCombo = function(field, context) { +iD.ui.preset.typeCombo = +iD.ui.preset.multiCombo = function(field, context) { var dispatch = d3.dispatch('change'), + isMulti = (field.type === 'multiCombo'), optstrings = field.strings && field.strings.options, optarray = field.options, snake_case = (field.snake_case || (field.snake_case === undefined)), - strings = {}, + combobox = d3.combobox().minItems(isMulti ? 1 : 2), + comboData = [], + multiData = [], + container, input, entity; + // ensure multiCombo field.key ends with a ':' + if (isMulti && field.key.match(/:$/) === null) { + field.key += ':'; + } + + function snake(s) { return s.replace(/\s+/g, '_'); } @@ -22,18 +33,76 @@ iD.ui.preset.typeCombo = function(field, context) { .join(';'); } - function optString() { - return _.find(_.keys(strings), function(k) { - return strings[k] === input.value(); - }); - } - function initCombo(selection) { - var d; + // returns the tag value for a display value + // (for multiCombo, dval should be the key suffix, not the entire key) + function tagValue(dval) { + dval = clean(dval || ''); + if (optstrings) { + var match = _.find(comboData, function(o) { return o.value === dval && o.key; }); + if (match) { + return match.key; + } + } + + if (field.type === 'typeCombo' && !dval) { + return 'yes'; + } + + return (snake_case ? snake(dval) : dval) || undefined; + } + + + // returns the display value for a tag value + // (for multiCombo, tval should be the key suffix, not the entire key) + function displayValue(tval) { + tval = tval || ''; + + if (optstrings) { + var match = _.find(comboData, function(o) { return o.key === tval && o.value; }); + if (match) { + return match.value; + } + } + + if (field.type === 'typeCombo' && tval.toLowerCase() === 'yes') { + return ''; + } + + return snake_case ? unsnake(tval) : tval; + } + + + function objectDifference(a, b) { + return _.reject(a, function(d1) { + return _.any(b, function(d2) { return d1.value === d2.value; }); + }); + } + + + function initCombo(selection, attachTo) { if (optstrings) { selection.attr('readonly', 'readonly'); - d = Object.keys(optstrings).map(function(k) { + selection.call(combobox, attachTo); + setStaticValues(setPlaceholder); + + } else if (optarray) { + selection.call(combobox, attachTo); + setStaticValues(setPlaceholder); + + } else if (context.taginfo()) { + selection.call(combobox.fetcher(setTaginfoValues), attachTo); + setTaginfoValues('', setPlaceholder); + } + } + + + function setStaticValues(callback) { + if (!(optstrings || optarray)) return; + + if (optstrings) { + comboData = Object.keys(optstrings).map(function(k) { var v = field.t('options.' + k, { 'default': optstrings[k] }); return { key: k, @@ -41,11 +110,9 @@ iD.ui.preset.typeCombo = function(field, context) { title: v }; }); - selection.call(d3.combobox().data(d)); - setPlaceholders(d); } else if (optarray) { - d = optarray.map(function(k) { + comboData = optarray.map(function(k) { var v = snake_case ? unsnake(k) : k; return { key: k, @@ -53,24 +120,24 @@ iD.ui.preset.typeCombo = function(field, context) { title: v }; }); - selection.call(d3.combobox().data(d)); - setPlaceholders(d); - - } else if (context.taginfo()) { - selection.call(d3.combobox().fetcher(taginfoValues)); - taginfoValues('', setPlaceholders); } + + combobox.data(objectDifference(comboData, multiData)); + if (callback) callback(comboData); } - function taginfoValues(q, callback) { - context.taginfo().values({ + + function setTaginfoValues(q, callback) { + var fn = isMulti ? 'multikeys' : 'values'; + context.taginfo()[fn]({ debounce: true, key: field.key, geometry: context.geometry(entity.id), - query: q + query: (isMulti ? field.key : '') + q }, function(err, data) { if (err) return; - var d = _.pluck(data, 'value').map(function(k) { + comboData = _.pluck(data, 'value').map(function(k) { + if (isMulti) k = k.replace(field.key, ''); var v = snake_case ? unsnake(k) : k; return { key: k, @@ -78,72 +145,151 @@ iD.ui.preset.typeCombo = function(field, context) { title: v }; }); - callback(d); + comboData = objectDifference(comboData, multiData); + if (callback) callback(comboData); }); } - function setPlaceholders(d) { - var vals = _.pluck(d, 'value').filter(function(s) { return s.length < 20; }), - placeholders = vals.length > 1 ? vals : _.pluck(d, 'key'); - input.attr('placeholder', field.placeholder() || - (placeholders.slice(0, 3).join(', ') + '…')); + function setPlaceholder(d) { + var ph; + if (isMulti) { + ph = field.placeholder() || t('inspector.add'); + } else { + var vals = _.pluck(d, 'value').filter(function(s) { return s.length < 20; }), + placeholders = vals.length > 1 ? vals : _.pluck(d, 'key'); + ph = field.placeholder() || placeholders.slice(0, 3).join(', '); + } + + input.attr('placeholder', ph + '…'); } + function change() { - var value = optString() || clean(input.value()); + var val = tagValue(input.value()), + t = {}; - if (snake_case) { - value = snake(value); - } - if (field.type === 'typeCombo' && !value) { - value = 'yes'; + if (isMulti) { + if (!val) return; + container.classed('active', false); + input.value(''); + field.keys.push(field.key + val); + t[field.key + val] = 'yes'; + + } else { + t[field.key] = val; } + dispatch.change(t); + } + + + function removeMultikey(d) { + d3.event.stopPropagation(); var t = {}; - t[field.key] = value || undefined; + t[d.key] = undefined; dispatch.change(t); } function combo(selection) { - input = selection.selectAll('input') + if (isMulti) { + container = selection.selectAll('ul').data([0]); + + container.enter() + .append('ul') + .attr('class', 'form-field-multiselect') + .on('click', function() { + window.setTimeout(function() { input.node().focus(); }, 100); + }); + + } else { + container = selection; + } + + input = container.selectAll('input') .data([0]); input.enter() .append('input') .attr('type', 'text') .attr('id', 'preset-input-' + field.id) - .call(initCombo); + .call(initCombo, selection); input .on('change', change) - .on('blur', change); + .on('blur', change) + .on('focus', function() { + if (isMulti) container.classed('active', true); + }); } - combo.tags = function(tags) { - var key = tags[field.key], - optstring = optString(), - value = strings[key] || key || ''; - if (field.type === 'typeCombo' && value.toLowerCase() === 'yes') { - value = ''; + combo.tags = function(tags) { + if (isMulti) { + multiData = []; + + // Build multiData array containing keys already set.. + Object.keys(tags).forEach(function(key) { + if (key.indexOf(field.key) !== 0 || tags[key].toLowerCase() !== 'yes') return; + + var suffix = key.substring(field.key.length); + multiData.push({ + key: key, + value: displayValue(suffix) + }); + }); + + // Set keys for form-field modified (needed for undo and reset buttons).. + field.keys = _.pluck(multiData, 'key'); + + // Exclude existing multikeys from combo options.. + var available = objectDifference(comboData, multiData); + combobox.data(available); + + // Hide "Add" button if there are no available multiselect options remaining.. + container.selectAll('.combobox-input, .combobox-caret') + .classed('hide', !available.length); + + + // Render chips + var chips = container.selectAll('.chips').data(multiData); + + var enter = chips.enter() + .insert('li', 'input') + .attr('class', 'chips'); + + enter.append('span'); + enter.append('a'); + + chips.select('span') + .text(function(d) { return d.value; }); + + chips.select('a') + .on('click', removeMultikey) + .attr('class', 'remove') + .text('×'); + + chips.exit() + .remove(); + + } else { + input.value(displayValue(tags[field.key])); } - if (!optstring && snake_case) { - value = unsnake(value); - } - input.value(value); }; + combo.focus = function() { input.node().focus(); }; + combo.entity = function(_) { if (!arguments.length) return entity; entity = _; return combo; }; + return d3.rebind(combo, dispatch, 'on'); }; diff --git a/js/lib/d3.combobox.js b/js/lib/d3.combobox.js index 93f6b052f..6304f1b8b 100644 --- a/js/lib/d3.combobox.js +++ b/js/lib/d3.combobox.js @@ -14,7 +14,7 @@ d3.combobox = function() { })); }; - var combobox = function(input, customBoundingRect) { + var combobox = function(input, attachTo) { var idx = -1, container = d3.select(document.body) .selectAll('div.combobox') @@ -223,7 +223,8 @@ d3.combobox = function() { options.exit() .remove(); - var rect = customBoundingRect ? customBoundingRect.node().getBoundingClientRect() : input.node().getBoundingClientRect(); + var node = attachTo ? attachTo.node() : input.node(), + rect = node.getBoundingClientRect(); container.style({ 'left': rect.left + 'px', From fb2dd89a65946a13e02eef99fbf31d76f7934c00 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 29 Apr 2016 21:15:14 -0400 Subject: [PATCH 11/16] Adjust multiCombo styling --- css/app.css | 29 ++++++++++++++++++++++------- js/id/ui/preset/combo.js | 5 +++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/css/app.css b/css/app.css index c3b1f8a68..36ec32e1e 100644 --- a/css/app.css +++ b/css/app.css @@ -298,6 +298,7 @@ ul li { list-style: none;} .al { left: 0; } .ar { right: 0; } +input.hide, div.hide, form.hide, button.hide, @@ -1018,7 +1019,7 @@ button.save.has-count .count::before { } .form-label button { - border-left: 1px solid #CCC; + border-left: 1px solid #ccc; width: 10%; height: 100%; border-radius: 0; @@ -1041,7 +1042,7 @@ button.save.has-count .count::before { .form-field > input, .form-field > textarea, .form-field .preset-input-wrap { - border: 1px solid #CCC; + border: 1px solid #ccc; min-height: 30px; border-top: 0; border-radius: 0 0 4px 4px; @@ -1053,23 +1054,30 @@ button.save.has-count .count::before { } .inspector-border { - border-bottom: 1px solid #CCC + border-bottom: 1px solid #ccc } /* Preset form (hover mode) */ .inspector-hover .checkselect label:last-of-type, .inspector-hover .preset-input-wrap .label, +.inspector-hover .form-field-multiselect, .inspector-hover input, .inspector-hover label { background: #ececec; } .inspector-hover a, +.inspector-hover .form-field-multiselect .chips, .inspector-hover .checkselect label:last-of-type { color: #666; } +.inspector-hover .form-field-multiselect .chips { + background: #eee; + border: 1px solid #ccc; +} + /* hide and remove from layout */ .inspector-hidden, .inspector-hover label input[type="checkbox"], @@ -1078,6 +1086,7 @@ button.save.has-count .count::before { .inspector-hover .toggle-list label span, .inspector-hover .inspector-inner .add-tag, .inspector-hover .inspector-inner .add-relation, +.inspector-hover .form-field-multiselect .combobox-input, .inspector-hover .toggle-list label.remove .icon { height: 0; width: 0; @@ -1093,6 +1102,7 @@ button.save.has-count .count::before { .inspector-hover .combobox-caret, .inspector-hover .entity-editor-pane .header button, .inspector-hover .spin-control, +.inspector-hover .form-field-multiselect .chips .remove, .inspector-hover .hide-toggle:before, .inspector-hover .more-fields, .inspector-hover .form-label-button-wrap, @@ -1221,14 +1231,14 @@ button.save.has-count .count::before { line-height: 25px; display: inline-block; padding: 2px 5px; - margin: 0 10px 5px 0; + margin: 3px; height: 30px; } .form-field-multiselect a { font-family: Arial, Helvetica, sans-serif !important; font-size: 16px !important; - line-height: 25px; + line-height: 24px; float: right; margin: 1px 0 0 5px; padding: 0; @@ -1237,8 +1247,13 @@ button.save.has-count .count::before { } .form-field-multiselect input { - border: 0px; - width: 110px; + border: 1px solid #ddd; + width: 100px; + margin: 3px; +} + +.form-field-multiselect .combobox-caret { + margin: 3px 3px 3px -30px; } .form-field-multiselect input:focus { diff --git a/js/id/ui/preset/combo.js b/js/id/ui/preset/combo.js index e29ab05f8..50590f809 100644 --- a/js/id/ui/preset/combo.js +++ b/js/id/ui/preset/combo.js @@ -247,9 +247,10 @@ iD.ui.preset.multiCombo = function(field, context) { var available = objectDifference(comboData, multiData); combobox.data(available); - // Hide "Add" button if there are no available multiselect options remaining.. + // Hide "Add" button if this field uses fixed set of + // translateable optstrings and they're all currently used.. container.selectAll('.combobox-input, .combobox-caret') - .classed('hide', !available.length); + .classed('hide', optstrings && !available.length); // Render chips From 8ecda6d9fcf61f93ceb1c6c3cf2fa288c22e2b2e Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 29 Apr 2016 21:31:56 -0400 Subject: [PATCH 12/16] Remove multiselect.js, remove test data (all functionality in multiselect.js has been moved to combo.js) --- Makefile | 1 - data/presets.yaml | 2 +- data/presets/fields.json | 8 +- data/presets/fields/internet_access.json | 2 +- ...ng_choices.json => recycling_accepts.json} | 2 +- data/presets/fields/sport_ice.json | 2 +- data/presets/presets.json | 2 +- data/presets/presets/amenity/recycling.json | 2 +- dist/locales/en.json | 2 +- index.html | 1 - js/id/ui/preset/multiselect.js | 198 ------------------ 11 files changed, 11 insertions(+), 211 deletions(-) rename data/presets/fields/{recycling/recycling_choices.json => recycling_accepts.json} (65%) delete mode 100644 js/id/ui/preset/multiselect.js diff --git a/Makefile b/Makefile index 5aabf9bb8..9752cec70 100644 --- a/Makefile +++ b/Makefile @@ -232,7 +232,6 @@ dist/iD.js: \ js/id/ui/preset/address.js \ js/id/ui/preset/check.js \ js/id/ui/preset/combo.js \ - js/id/ui/preset/multiselect.js \ js/id/ui/preset/cycleway.js \ js/id/ui/preset/input.js \ js/id/ui/preset/localized.js \ diff --git a/data/presets.yaml b/data/presets.yaml index 572c41e69..16690691d 100644 --- a/data/presets.yaml +++ b/data/presets.yaml @@ -773,7 +773,7 @@ en: railway: # 'railway=*' label: Type - recycling/recycling_choices: + recycling_accepts: # 'recycling:=*' label: Accepts ref: diff --git a/data/presets/fields.json b/data/presets/fields.json index 0a88d5232..d6ac0a9b9 100644 --- a/data/presets/fields.json +++ b/data/presets/fields.json @@ -653,7 +653,7 @@ }, "internet_access": { "key": "internet_access", - "type": "multiselect", + "type": "combo", "label": "Internet Access", "strings": { "options": { @@ -1020,9 +1020,9 @@ "type": "typeCombo", "label": "Type" }, - "recycling/recycling_choices": { + "recycling_accepts": { "key": "recycling:", - "type": "multiselect", + "type": "multiCombo", "label": "Accepts" }, "ref": { @@ -1234,7 +1234,7 @@ }, "sport_ice": { "key": "sport", - "type": "multiselect", + "type": "combo", "label": "Sport", "options": [ "skating", diff --git a/data/presets/fields/internet_access.json b/data/presets/fields/internet_access.json index ec5ca1f7c..a628dfd5f 100644 --- a/data/presets/fields/internet_access.json +++ b/data/presets/fields/internet_access.json @@ -1,6 +1,6 @@ { "key": "internet_access", - "type": "multiselect", + "type": "combo", "label": "Internet Access", "strings": { "options": { diff --git a/data/presets/fields/recycling/recycling_choices.json b/data/presets/fields/recycling_accepts.json similarity index 65% rename from data/presets/fields/recycling/recycling_choices.json rename to data/presets/fields/recycling_accepts.json index 57398e714..0866f7884 100644 --- a/data/presets/fields/recycling/recycling_choices.json +++ b/data/presets/fields/recycling_accepts.json @@ -1,5 +1,5 @@ { "key": "recycling:", - "type": "multiselect", + "type": "multiCombo", "label": "Accepts" } diff --git a/data/presets/fields/sport_ice.json b/data/presets/fields/sport_ice.json index 1d42d5fb8..7e5133bf9 100644 --- a/data/presets/fields/sport_ice.json +++ b/data/presets/fields/sport_ice.json @@ -1,6 +1,6 @@ { "key": "sport", - "type": "multiselect", + "type": "combo", "label": "Sport", "options": [ "skating", diff --git a/data/presets/presets.json b/data/presets/presets.json index 3b1318b06..1cc17d080 100644 --- a/data/presets/presets.json +++ b/data/presets/presets.json @@ -1581,7 +1581,7 @@ "fields": [ "operator", "address", - "recycling/recycling_choices" + "recycling_accepts" ], "geometry": [ "point", diff --git a/data/presets/presets/amenity/recycling.json b/data/presets/presets/amenity/recycling.json index 23865fe5e..2cabff15b 100644 --- a/data/presets/presets/amenity/recycling.json +++ b/data/presets/presets/amenity/recycling.json @@ -3,7 +3,7 @@ "fields": [ "operator", "address", - "recycling/recycling_choices" + "recycling_accepts" ], "geometry": [ "point", diff --git a/dist/locales/en.json b/dist/locales/en.json index cdca08f78..d1075f0e1 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -1281,7 +1281,7 @@ "railway": { "label": "Type" }, - "recycling/recycling_choices": { + "recycling_accepts": { "label": "Accepts" }, "ref": { diff --git a/index.html b/index.html index fc67bc970..80d184a2f 100644 --- a/index.html +++ b/index.html @@ -129,7 +129,6 @@ - diff --git a/js/id/ui/preset/multiselect.js b/js/id/ui/preset/multiselect.js deleted file mode 100644 index c0d5db19f..000000000 --- a/js/id/ui/preset/multiselect.js +++ /dev/null @@ -1,198 +0,0 @@ -iD.ui.preset.multiselect = function(field, context) { - var dispatch = d3.dispatch('init', 'change'), - optstrings = field.strings && field.strings.options, - optarray = field.options, - strings = {}, - multiselectContainer, - combobox, - comboboxData, - input, - isInitialized; - - // ensure field.key ends with a ':' - if (field.key.match(/.*:$/) === null) { - field.key += ':'; - } - - function getOptStringKey(val) { - if (optstrings) { - var match = _.find(strings, function(o) { - return o.value === val; - }); - return match && match.key; - } - } - - function getOptStringVal(key) { - if (optstrings) { - var match = _.find(strings, function(o) { - return o.key === key; - }); - return match && match.value; - } - } - - function objectDifference(a, b) { - var bObj = {}; - b.forEach(function(obj){ - bObj[obj.key] = obj; - }); - // Return all elements in a, unless in b - return a.filter(function(obj) { - return !(obj.key in bObj); - }); - } - - function multiselect(selection) { - isInitialized = false; - combobox = d3.combobox().minItems(1); - - multiselectContainer = selection.selectAll('ul').data([0]); - - multiselectContainer.enter() - .append('ul') - .on('click', function() { - window.setTimeout(function(){input.node().focus();}, 100); - }) - .attr('class', 'form-field-multiselect'); - - input = multiselectContainer.selectAll('input') - .data([0]); - - var enter = input.enter() - .append('input') - .attr('type', 'text') - .attr('id', 'preset-input-' + field.id); - - if (optstrings) { enter.attr('readonly', 'readonly'); } - - input - .call(function() {combobox(input, selection);}) - .on('change', change) - .on('blur', change) - .on('focus', function() {multiselectContainer.classed('active', true);}) - .each(function() { - if (optstrings) { - strings = Object.keys(optstrings).map(function(k) { - return { - key: k, - value: field.t('options.' + k, { 'default': optstrings[k] }) - }; - }); - dispatch.init(); - isInitialized = true; - } else if (optarray) { - strings = optarray.map(function(k) {return {key: k, value: k};}); - dispatch.init(); - isInitialized = true; - } else if (context.taginfo()) { - context.taginfo().multikeys({query: field.key}, function(err, data) { - if (!err) { - strings = data.map(function(k) { - var d = k.value.replace(field.key, ''); - return { - key: d, - value: d - }; - }); - dispatch.init(); - isInitialized = true; - } - }); - } - }); - } - - function updateStrings(tagsData) { - comboboxData = objectDifference(strings, tagsData); - combobox.data(comboboxData.map(comboValues)); - input.attr('placeholder', (field.placeholder() || t('inspector.add')) + '…'); - } - - function update(data) { - var chips = multiselectContainer.selectAll('.chips').data(data); - - var enter = chips.enter() - .insert('li', 'input') - .attr('class', 'chips'); - - enter.append('span'); - enter.append('a'); - - chips.select('span').text(function(d) {return d.value;}); - - chips.select('a') - .on('click', removeKey) - .attr('class', 'remove') - .text('×'); - - chips.exit().remove(); - } - - function comboValues(d) { - return { - value: d.value, - title: d.value - }; - } - - function change() { - multiselectContainer.classed('active', false); - var key = getOptStringKey(input.value()) || input.value(); - if (key && key !== '') { - var t = {}; - t[field.key + key] = 'yes'; - input.value(''); - field.keys.push(field.key + key); - dispatch.change(t); - } - } - - function removeKey(d) { - d3.event.stopPropagation(); - var t = {}; - t[field.key + d.key] = undefined; - dispatch.change(t); - } - - multiselect.tags = function(tags) { - var tagsData = []; - Object.keys(tags).forEach(function(d) { - if (d.indexOf(field.key) > -1 && tags[d] === 'yes') { - var datum = d.replace(field.key, ''); - - if (!optstrings) { - return tagsData.push({ - key: datum, - value: datum - }); - } - // discards any pair not found in optstrings - if (optstrings && getOptStringVal(datum)) { - return tagsData.push({ - key: datum, - value: getOptStringVal(datum) - }); - } - } - }); - - field.keys = _.map(_.pluck(tagsData, 'key'), function(v) { return field.key + v; }); - - update(tagsData); - - if (isInitialized) { - updateStrings(tagsData); - } else { - dispatch.on('init', function () { - updateStrings(tagsData); - }); - } - }; - - multiselect.focus = function() { - input.node().focus(); - }; - - return d3.rebind(multiselect, dispatch, 'on'); -}; From 4b768057a5f035c84802223528a25b89b8253834 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 29 Apr 2016 21:37:51 -0400 Subject: [PATCH 13/16] Rename multiselect -> multicombo --- css/app.css | 28 ++++++++++++++-------------- js/id/ui/preset/combo.js | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/css/app.css b/css/app.css index 36ec32e1e..22f85f441 100644 --- a/css/app.css +++ b/css/app.css @@ -1061,19 +1061,19 @@ button.save.has-count .count::before { .inspector-hover .checkselect label:last-of-type, .inspector-hover .preset-input-wrap .label, -.inspector-hover .form-field-multiselect, +.inspector-hover .form-field-multicombo, .inspector-hover input, .inspector-hover label { background: #ececec; } .inspector-hover a, -.inspector-hover .form-field-multiselect .chips, +.inspector-hover .form-field-multicombo .chips, .inspector-hover .checkselect label:last-of-type { color: #666; } -.inspector-hover .form-field-multiselect .chips { +.inspector-hover .form-field-multicombo .chips { background: #eee; border: 1px solid #ccc; } @@ -1086,7 +1086,7 @@ button.save.has-count .count::before { .inspector-hover .toggle-list label span, .inspector-hover .inspector-inner .add-tag, .inspector-hover .inspector-inner .add-relation, -.inspector-hover .form-field-multiselect .combobox-input, +.inspector-hover .form-field-multicombo .combobox-input, .inspector-hover .toggle-list label.remove .icon { height: 0; width: 0; @@ -1102,7 +1102,7 @@ button.save.has-count .count::before { .inspector-hover .combobox-caret, .inspector-hover .entity-editor-pane .header button, .inspector-hover .spin-control, -.inspector-hover .form-field-multiselect .chips .remove, +.inspector-hover .form-field-multicombo .chips .remove, .inspector-hover .hide-toggle:before, .inspector-hover .more-fields, .inspector-hover .form-label-button-wrap, @@ -1203,9 +1203,9 @@ button.save.has-count .count::before { border-bottom-right-radius: 4px; } -/* preset form multiselect */ +/* preset form multicombo */ -.form-field-multiselect { +.form-field-multicombo { border: 1px solid #cfcfcf; border-top: 0px; padding: 5px 0 5px 10px; @@ -1215,16 +1215,16 @@ button.save.has-count .count::before { overflow: hidden; } -.form-field-multiselect:focus { +.form-field-multicombo:focus { border-bottom: 0px; } -.form-field-multiselect.active { +.form-field-multicombo.active { border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; } -.form-field-multiselect li { +.form-field-multicombo li { background-color: #eff2f7; border: 1px solid #ccd5e3; border-radius: 4px; @@ -1235,7 +1235,7 @@ button.save.has-count .count::before { height: 30px; } -.form-field-multiselect a { +.form-field-multicombo a { font-family: Arial, Helvetica, sans-serif !important; font-size: 16px !important; line-height: 24px; @@ -1246,17 +1246,17 @@ button.save.has-count .count::before { color: #a6b4ce; } -.form-field-multiselect input { +.form-field-multicombo input { border: 1px solid #ddd; width: 100px; margin: 3px; } -.form-field-multiselect .combobox-caret { +.form-field-multicombo .combobox-caret { margin: 3px 3px 3px -30px; } -.form-field-multiselect input:focus { +.form-field-multicombo input:focus { border-radius: 4px !important; } diff --git a/js/id/ui/preset/combo.js b/js/id/ui/preset/combo.js index 50590f809..fa84bf134 100644 --- a/js/id/ui/preset/combo.js +++ b/js/id/ui/preset/combo.js @@ -198,7 +198,7 @@ iD.ui.preset.multiCombo = function(field, context) { container.enter() .append('ul') - .attr('class', 'form-field-multiselect') + .attr('class', 'form-field-multicombo') .on('click', function() { window.setTimeout(function() { input.node().focus(); }, 100); }); From ab3340e816755d1d7035ce955af9735fc5941bdd Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 29 Apr 2016 22:25:06 -0400 Subject: [PATCH 14/16] Don't leave edit mode on pressing enter if a combobox is active --- js/id/ui/preset.js | 3 ++- js/lib/d3.combobox.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/js/id/ui/preset.js b/js/id/ui/preset.js index 60555e938..02ec1a0f2 100644 --- a/js/id/ui/preset.js +++ b/js/id/ui/preset.js @@ -159,7 +159,8 @@ iD.ui.preset = function(context) { .call(field.input) .selectAll('input') .on('keydown', function() { - if (d3.event.keyCode === 13) { // enter + // if user presses enter, and combobox is not active, accept edits.. + if (d3.event.keyCode === 13 && d3.select('.combobox').empty()) { context.enter(iD.modes.Browse(context)); } }) diff --git a/js/lib/d3.combobox.js b/js/lib/d3.combobox.js index 6304f1b8b..97a2fe3cd 100644 --- a/js/lib/d3.combobox.js +++ b/js/lib/d3.combobox.js @@ -154,6 +154,7 @@ d3.combobox = function() { } function nav(dir) { + if (!suggestions.length) return; idx = Math.max(Math.min(idx + dir, suggestions.length - 1), 0); input.property('value', suggestions[idx].value); render(); From 5c68b2cc0f1d08fc0ec6d671eb8e863b413d2f3d Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 29 Apr 2016 22:39:31 -0400 Subject: [PATCH 15/16] Avoid tabbing to field buttons in the entity editor --- js/id/ui/preset.js | 1 + js/id/ui/preset/input.js | 6 ++++-- js/id/ui/preset/localized.js | 1 + js/id/ui/preset/wikipedia.js | 1 + js/id/ui/tag_reference.js | 1 + 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/js/id/ui/preset.js b/js/id/ui/preset.js index 02ec1a0f2..a1374d497 100644 --- a/js/id/ui/preset.js +++ b/js/id/ui/preset.js @@ -125,6 +125,7 @@ iD.ui.preset = function(context) { wrap.append('button') .attr('class', 'remove-icon') + .attr('tabindex', -1) .call(iD.svg.Icon('#operation-delete')); wrap.append('button') diff --git a/js/id/ui/preset/input.js b/js/id/ui/preset/input.js index b65748a2b..cba65395c 100644 --- a/js/id/ui/preset/input.js +++ b/js/id/ui/preset/input.js @@ -32,11 +32,13 @@ iD.ui.preset.url = function(field) { enter.append('button') .datum(1) - .attr('class', 'increment'); + .attr('class', 'increment') + .attr('tabindex', -1); enter.append('button') .datum(-1) - .attr('class', 'decrement'); + .attr('class', 'decrement') + .attr('tabindex', -1); spinControl.selectAll('button') .on('click', function(d) { diff --git a/js/id/ui/preset/localized.js b/js/id/ui/preset/localized.js index 18d27ff70..d80273a80 100644 --- a/js/id/ui/preset/localized.js +++ b/js/id/ui/preset/localized.js @@ -32,6 +32,7 @@ iD.ui.preset.localized = function(field, context) { translateButton.enter() .append('button') .attr('class', 'button-input-action localized-add minor') + .attr('tabindex', -1) .call(iD.svg.Icon('#icon-plus')) .call(bootstrap.tooltip() .title(t('translate.translate')) diff --git a/js/id/ui/preset/wikipedia.js b/js/id/ui/preset/wikipedia.js index 5ac04cef3..389095814 100644 --- a/js/id/ui/preset/wikipedia.js +++ b/js/id/ui/preset/wikipedia.js @@ -61,6 +61,7 @@ iD.ui.preset.wikipedia = function(field, context) { link.enter().append('a') .attr('class', 'wiki-link button-input-action minor') + .attr('tabindex', -1) .attr('target', '_blank') .call(iD.svg.Icon('#icon-out-link', 'inline')); } diff --git a/js/id/ui/tag_reference.js b/js/id/ui/tag_reference.js index 2e027f811..fd95faa61 100644 --- a/js/id/ui/tag_reference.js +++ b/js/id/ui/tag_reference.js @@ -69,6 +69,7 @@ iD.ui.TagReference = function(tag, context) { body .append('a') .attr('target', '_blank') + .attr('tabindex', -1) .attr('href', 'https://wiki.openstreetmap.org/wiki/' + docs.title) .call(iD.svg.Icon('#icon-out-link', 'inline')) .append('span') From 980bd23e8aa37efa861e8d3e729a04763b3a8b4a Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sat, 30 Apr 2016 00:11:27 -0400 Subject: [PATCH 16/16] Retain focus on input when enter/tab with a value --- js/id/ui/preset/combo.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/js/id/ui/preset/combo.js b/js/id/ui/preset/combo.js index fa84bf134..50be9bf1a 100644 --- a/js/id/ui/preset/combo.js +++ b/js/id/ui/preset/combo.js @@ -175,6 +175,7 @@ iD.ui.preset.multiCombo = function(field, context) { input.value(''); field.keys.push(field.key + val); t[field.key + val] = 'yes'; + window.setTimeout(function() { input.node().focus(); }, 10); } else { t[field.key] = val; @@ -200,7 +201,7 @@ iD.ui.preset.multiCombo = function(field, context) { .append('ul') .attr('class', 'form-field-multicombo') .on('click', function() { - window.setTimeout(function() { input.node().focus(); }, 100); + window.setTimeout(function() { input.node().focus(); }, 10); }); } else { @@ -218,10 +219,18 @@ iD.ui.preset.multiCombo = function(field, context) { input .on('change', change) - .on('blur', change) - .on('focus', function() { - if (isMulti) container.classed('active', true); - }); + .on('blur', change); + + if (isMulti) { + combobox + .on('accept', function() { + input.node().blur(); + input.node().focus(); + }); + + input + .on('focus', function() { container.classed('active', true); }); + } }