diff --git a/css/app.css b/css/app.css index ea6d5bd59..22f85f441 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-multicombo, .inspector-hover input, .inspector-hover label { background: #ececec; } .inspector-hover a, +.inspector-hover .form-field-multicombo .chips, .inspector-hover .checkselect label:last-of-type { color: #666; } +.inspector-hover .form-field-multicombo .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-multicombo .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-multicombo .chips .remove, .inspector-hover .hide-toggle:before, .inspector-hover .more-fields, .inspector-hover .form-label-button-wrap, @@ -1193,6 +1203,63 @@ button.save.has-count .count::before { border-bottom-right-radius: 4px; } +/* preset form multicombo */ + +.form-field-multicombo { + 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-multicombo:focus { + border-bottom: 0px; +} + +.form-field-multicombo.active { + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; +} + +.form-field-multicombo li { + background-color: #eff2f7; + border: 1px solid #ccd5e3; + border-radius: 4px; + line-height: 25px; + display: inline-block; + padding: 2px 5px; + margin: 3px; + height: 30px; +} + +.form-field-multicombo a { + font-family: Arial, Helvetica, sans-serif !important; + font-size: 16px !important; + line-height: 24px; + float: right; + margin: 1px 0 0 5px; + padding: 0; + cursor: pointer; + color: #a6b4ce; +} + +.form-field-multicombo input { + border: 1px solid #ddd; + width: 100px; + margin: 3px; +} + +.form-field-multicombo .combobox-caret { + margin: 3px 3px 3px -30px; +} + +.form-field-multicombo input:focus { + border-radius: 4px !important; +} + /* preset form cycleway */ .form-field-cycleway .preset-input-wrap li { 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/data/presets.yaml b/data/presets.yaml index ab57cbb83..53498cc55 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_accepts: + # 'recycling:=*' + label: Accepts ref: # 'ref=*' label: Reference diff --git a/data/presets/fields.json b/data/presets/fields.json index f7b44f216..3285f09ef 100644 --- a/data/presets/fields.json +++ b/data/presets/fields.json @@ -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_accepts": { + "key": "recycling:", + "type": "multiCombo", + "label": "Accepts" }, "ref": { "key": "ref", 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/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/recycling_accepts.json b/data/presets/fields/recycling_accepts.json new file mode 100644 index 000000000..0866f7884 --- /dev/null +++ b/data/presets/fields/recycling_accepts.json @@ -0,0 +1,5 @@ +{ + "key": "recycling:", + "type": "multiCombo", + "label": "Accepts" +} diff --git a/data/presets/presets.json b/data/presets/presets.json index 2139318cf..dc5a59a6c 100644 --- a/data/presets/presets.json +++ b/data/presets/presets.json @@ -1600,13 +1600,7 @@ "fields": [ "operator", "address", - "recycling/type", - "recycling/cans", - "recycling/glass_bottles", - "recycling/paper", - "recycling/glass", - "recycling/plastic", - "recycling/clothes" + "recycling_accepts" ], "geometry": [ "point", diff --git a/data/presets/presets/amenity/recycling.json b/data/presets/presets/amenity/recycling.json index 5651c5774..2cabff15b 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_accepts" ], "geometry": [ "point", diff --git a/data/presets/schema/field.json b/data/presets/schema/field.json index e43366ce5..7b73733ee 100644 --- a/data/presets/schema/field.json +++ b/data/presets/schema/field.json @@ -56,6 +56,7 @@ "defaultcheck", "text", "maxspeed", + "multiCombo", "number", "tel", "email", diff --git a/dist/locales/en.json b/dist/locales/en.json index b14feda02..c23a6af30 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", @@ -1280,30 +1281,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_accepts": { + "label": "Accepts" }, "ref": { "label": "Reference" diff --git a/js/id/services/taginfo.js b/js/id/services/taginfo.js index 4bd7c8750..76148c014 100644 --- a/js/id/services/taginfo.js +++ b/js/id/services/taginfo.js @@ -34,14 +34,24 @@ iD.services.taginfo = function() { return _.omit(parameters, 'geometry', 'debounce'); } - 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; }; + function filterKeys(type) { + var count_type = type ? 'count_' + type : 'count_all'; + return function(d) { + return parseFloat(d[count_type]) > 2500 || d.in_wiki; + }; } - function popularValues() { - return function(d) { return parseFloat(d.fraction) > 0.01 || d.in_wiki; }; + function filterMultikeys() { + return function(d) { + return (d.key.match(/:/g) || []).length === 1; // exactly one ':' + }; + } + + function filterValues() { + return function(d) { + if (d.value.match(/[A-Z*;,]/) !== null) return false; // exclude some punctuation, uppercase letters + return parseFloat(d.fraction) > 0.0 || d.in_wiki; + }; } function valKey(d) { @@ -95,7 +105,24 @@ 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)); + var f = filterKeys(parameters.filter); + callback(null, d.data.filter(f).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); + var f = filterMultikeys(); + callback(null, d.data.filter(f).map(valKey)); }); }; @@ -110,7 +137,8 @@ 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); + var f = filterValues(); + callback(null, d.data.filter(f).map(valKeyDescription)); }); }; diff --git a/js/id/ui/preset.js b/js/id/ui/preset.js index 60555e938..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') @@ -159,7 +160,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/id/ui/preset/combo.js b/js/id/ui/preset/combo.js index 3561e7352..50be9bf1a 100644 --- a/js/id/ui/preset/combo.js +++ b/js/id/ui/preset/combo.js @@ -1,11 +1,23 @@ 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 = {}, - input; + 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, '_'); @@ -21,103 +33,273 @@ iD.ui.preset.typeCombo = function(field, context) { .join(';'); } - function optString() { - return _.find(_.keys(strings), function(k) { - return strings[k] === input.value(); - }); + + // 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; } - function combo(selection) { - var combobox = d3.combobox(); - input = selection.selectAll('input') - .data([0]); + // 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 || ''; - var enter = input.enter() - .append('input') - .attr('type', 'text') - .attr('id', 'preset-input-' + field.id); + if (optstrings) { + var match = _.find(comboData, function(o) { return o.key === tval && o.value; }); + if (match) { + return match.value; + } + } - if (optstrings) { enter.attr('readonly', 'readonly'); } + if (field.type === 'typeCombo' && tval.toLowerCase() === 'yes') { + return ''; + } - 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(); - } - }); - } - }); + return snake_case ? unsnake(tval) : tval; + } - function stringsLoaded() { - var keys = _.keys(strings), - strs = [], - placeholders; - 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; - })); + function objectDifference(a, b) { + return _.reject(a, function(d1) { + return _.any(b, function(d2) { return d1.value === d2.value; }); + }); + } - placeholders = strs.length > 1 ? strs : keys; - input.attr('placeholder', field.placeholder() || - (placeholders.slice(0, 3).join(', ') + '...')); + + function initCombo(selection, attachTo) { + if (optstrings) { + selection.attr('readonly', 'readonly'); + 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, + value: v, + title: v + }; + }); + + } else if (optarray) { + comboData = optarray.map(function(k) { + var v = snake_case ? unsnake(k) : k; + return { + key: k, + value: v, + title: v + }; + }); + } + + combobox.data(objectDifference(comboData, multiData)); + if (callback) callback(comboData); + } + + + function setTaginfoValues(q, callback) { + var fn = isMulti ? 'multikeys' : 'values'; + context.taginfo()[fn]({ + debounce: true, + key: field.key, + geometry: context.geometry(entity.id), + query: (isMulti ? field.key : '') + q + }, function(err, data) { + if (err) return; + comboData = _.pluck(data, 'value').map(function(k) { + if (isMulti) k = k.replace(field.key, ''); + var v = snake_case ? unsnake(k) : k; + return { + key: k, + value: v, + title: v + }; + }); + comboData = objectDifference(comboData, multiData); + if (callback) callback(comboData); + }); + } + + + 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'; + window.setTimeout(function() { input.node().focus(); }, 10); + + } else { + t[field.key] = val; } - var t = {}; - t[field.key] = value || undefined; dispatch.change(t); } - combo.tags = function(tags) { - var key = tags[field.key], - optstring = optString(), - value = strings[key] || key || ''; - if (field.type === 'typeCombo' && value.toLowerCase() === 'yes') { - value = ''; + function removeMultikey(d) { + d3.event.stopPropagation(); + var t = {}; + t[d.key] = undefined; + dispatch.change(t); + } + + + function combo(selection) { + if (isMulti) { + container = selection.selectAll('ul').data([0]); + + container.enter() + .append('ul') + .attr('class', 'form-field-multicombo') + .on('click', function() { + window.setTimeout(function() { input.node().focus(); }, 10); + }); + + } else { + container = selection; } - if (!optstring && snake_case) { - value = unsnake(value); + + input = container.selectAll('input') + .data([0]); + + input.enter() + .append('input') + .attr('type', 'text') + .attr('id', 'preset-input-' + field.id) + .call(initCombo, selection); + + input + .on('change', change) + .on('blur', change); + + if (isMulti) { + combobox + .on('accept', function() { + input.node().blur(); + input.node().focus(); + }); + + input + .on('focus', function() { container.classed('active', true); }); + } + } + + + 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 this field uses fixed set of + // translateable optstrings and they're all currently used.. + container.selectAll('.combobox-input, .combobox-caret') + .classed('hide', optstrings && !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])); } - 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/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 0df7ae134..657331fbe 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 1f4ba6bfd..b14ac9655 100644 --- a/js/id/ui/preset/wikipedia.js +++ b/js/id/ui/preset/wikipedia.js @@ -62,6 +62,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') diff --git a/js/lib/d3.combobox.js b/js/lib/d3.combobox.js index fbe4ca025..97a2fe3cd 100644 --- a/js/lib/d3.combobox.js +++ b/js/lib/d3.combobox.js @@ -14,7 +14,7 @@ d3.combobox = function() { })); }; - var combobox = function(input) { + var combobox = function(input, attachTo) { var idx = -1, container = d3.select(document.body) .selectAll('div.combobox') @@ -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(); @@ -223,7 +224,8 @@ d3.combobox = function() { options.exit() .remove(); - var rect = input.node().getBoundingClientRect(); + var node = attachTo ? attachTo.node() : input.node(), + rect = node.getBoundingClientRect(); container.style({ 'left': rect.left + 'px', diff --git a/test/spec/services/taginfo.js b/test/spec/services/taginfo.js index 76929aaee..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,9 +42,8 @@ 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); server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/keys/all"), @@ -56,9 +55,24 @@ 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); server.respondWith("GET", new RegExp("https://taginfo.openstreetmap.org/api/4/keys/all"), @@ -71,10 +85,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"), @@ -87,15 +129,46 @@ 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); 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("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":"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"}]); @@ -105,7 +178,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"),