diff --git a/lib/utils/gestures.html b/lib/utils/gestures.html index 738940f406..ca38ec3173 100644 --- a/lib/utils/gestures.html +++ b/lib/utils/gestures.html @@ -97,6 +97,55 @@ /** @type {(function(MouseEvent): void | undefined)} */ GestureRecognizer.prototype.click; + // keep track of any labels hit by the mouseCanceller + /** @type {!Array} */ + const clickedLabels = []; + + /** @type {!Map} */ + const labellable = { + 'button': true, + 'input': true, + 'keygen': true, + 'meter': true, + 'output': true, + 'textarea': true, + 'progress': true, + 'select': true + }; + + /** + * @param {HTMLElement} el Element to check labelling status + * @return {boolean} element can have labels + */ + function canBeLabelled(el) { + return labellable[el.localName] || false; + } + + /** + * @param {HTMLElement} el Element that may be labelled. + * @return {!Array} Relevant label for `el` + */ + function matchingLabels(el) { + /** @type {!Array} */ + let labels = el.labels; + // IE doesn't have `labels` and Safari doesn't populate `labels` + // if element is in a shadowroot. + // In this instance, finding the non-ancestor labels is enough, + // as the mouseCancellor code will handle ancstor labels + if (!labels || !labels.length) { + labels = []; + let root = el.getRootNode(); + // if there is an id on `el`, check for all labels with a matching `for` attribute + if (el.id) { + let matching = root.querySelectorAll(`label[for = ${el.id}]`); + for (let i = 0; i < matching.length; i++) { + labels.push(/** @type {!HTMLLabelElement} */(matching[i])); + } + } + } + return labels; + } + // touch will make synthetic mouse events // `preventDefault` on touchend will cancel them, // but this breaks `` focus and link clicks @@ -115,14 +164,31 @@ mouseEvent[HANDLED_OBJ] = {skip: true}; // disable "ghost clicks" if (mouseEvent.type === 'click') { + let clickFromLabel = false; let path = mouseEvent.composedPath && mouseEvent.composedPath(); if (path) { for (let i = 0; i < path.length; i++) { + if (path[i].nodeType === Node.ELEMENT_NODE) { + if (path[i].localName === 'label') { + clickedLabels.push(path[i]); + } else if (canBeLabelled(path[i])) { + let ownerLabels = matchingLabels(path[i]); + // check if one of the clicked labels is labelling this element + for (let j = 0; j < ownerLabels.length; j++) { + clickFromLabel = clickFromLabel || clickedLabels.indexOf(ownerLabels[j]) > -1; + } + } + } if (path[i] === POINTERSTATE.mouse.target) { return; } } } + // if one of the clicked labels was labelling the target element, + // this is not a ghost click + if (clickFromLabel) { + return; + } mouseEvent.preventDefault(); mouseEvent.stopPropagation(); } @@ -137,6 +203,8 @@ for (let i = 0, en; i < events.length; i++) { en = events[i]; if (setup) { + // reset clickLabels array + clickedLabels.length = 0; document.addEventListener(en, mouseCanceller, true); } else { document.removeEventListener(en, mouseCanceller, true); diff --git a/test/smoke/label-click.html b/test/smoke/label-click.html new file mode 100644 index 0000000000..c2ca4d3fa1 --- /dev/null +++ b/test/smoke/label-click.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/unit/gestures-elements.html b/test/unit/gestures-elements.html index 777f2de556..534b4edfbf 100644 --- a/test/unit/gestures-elements.html +++ b/test/unit/gestures-elements.html @@ -212,3 +212,34 @@ }); + + + + + + + + + + \ No newline at end of file diff --git a/test/unit/gestures.html b/test/unit/gestures.html index 4c8a92b4cb..30866d06fa 100644 --- a/test/unit/gestures.html +++ b/test/unit/gestures.html @@ -559,6 +559,61 @@ assert.equal(count, 1); Polymer.Gestures.remove(window, 'tap', increment); }); + + suite('native label click', function() { + + test('native label click', function() { + let el = document.createElement('x-native-label'); + document.body.appendChild(el); + let target = el.$.label; + // simulate the event sequence of a touch on the screen + let touches = [{ + clientX: 0, + clientY: 0, + identifier: 1, + // target is set to the element with `addEventListener`, which is `target` + target + }]; + let touchstart = new CustomEvent('touchstart', {bubbles: true, composed: true}); + touchstart.changedTouches = touchstart.touches = touches; + target.dispatchEvent(touchstart); + let touchend = new CustomEvent('touchend', {bubbles: true, composed: true}); + touchend.touches = touchend.changedTouches = touches; + target.dispatchEvent(touchend); + // simulate a mouse click on the label + let click = new MouseEvent('click', {bubbles: true, composed: true}); + target.dispatchEvent(click); + // check that the mouse click on the label will activate the checkbox + assert.equal(el.$.check.checked, true, 'checkbox should be checked'); + document.body.removeChild(el); + }); + }); + + test('label click with nested element', function() { + let el = document.createElement('x-native-label-nested'); + document.body.appendChild(el); + let target = el.$.label; + // simulate the event sequence of a touch on the screen + let touches = [{ + clientX: 0, + clientY: 0, + identifier: 1, + // target is set to the element with `addEventListener`, which is `target` + target + }]; + let touchstart = new CustomEvent('touchstart', {bubbles: true, composed: true}); + touchstart.changedTouches = touchstart.touches = touches; + target.dispatchEvent(touchstart); + let touchend = new CustomEvent('touchend', {bubbles: true, composed: true}); + touchend.touches = touchend.changedTouches = touches; + target.dispatchEvent(touchend); + // simulate a mouse click on the label + let click = new MouseEvent('click', {bubbles: true, composed: true}); + target.dispatchEvent(click); + // check that the mouse click on the label will activate the checkbox + assert.equal(el.$.check.checked, true, 'checkbox should be checked'); + document.body.removeChild(el); + }); });