From 466c3c689938163cc5daceb935ce2e3b6e685396 Mon Sep 17 00:00:00 2001 From: John Cowen Date: Wed, 15 May 2019 14:48:16 +0100 Subject: [PATCH] ui: Allow text selection of clickable elements and their contents (#5770) * ui: Allow text selection of clickable elements and their contents This commit disables a click on mousedown be removing the `href` attribute and moving it to a `data-href` attribute. On mouseup it will only move it back if there is no selection. This means that an anchor will only be followed on click _if_ there is no selection. This fixes the fact that whenever you select some copy within a clickable element it immediately throws you into the linked page when you release your mouse. Further notes: We use the `isCollapsed` property here which 'seems' to be classed as 'experimental' in one place where I researched it: https://developer.mozilla.org/en-US/docs/Web/API/Selection/isCollapsed Although in others it makes no mention of this 'experimental' e.g: - https://webplatform.github.io/docs/dom/Selection/isCollapsed/ - https://w3c.github.io/selection-api/#dom-selection-iscollapsed I may have gone a little overboard in feature detection for this, but I conscious of that fact that if `isCollapsed` doesn't exist at some point in the future (something that seems unlikely). The code here will have no effect on the UI. But I'd specifically like a second pair of eyes on that. * ui: Don't break right click, detects a secondary click on mousedown * ui: Put anchor selection capability behind an ENV var --- ui-v2/app/instance-initializers/selection.js | 62 +++++++++++++++++++ ui-v2/app/utils/dom/click-first-anchor.js | 20 +++--- ui-v2/config/environment.js | 3 + .../unit/utils/dom/click-first-anchor-test.js | 8 +-- 4 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 ui-v2/app/instance-initializers/selection.js diff --git a/ui-v2/app/instance-initializers/selection.js b/ui-v2/app/instance-initializers/selection.js new file mode 100644 index 000000000..e106234fc --- /dev/null +++ b/ui-v2/app/instance-initializers/selection.js @@ -0,0 +1,62 @@ +import env from 'consul-ui/env'; + +const SECONDARY_BUTTON = 2; +const isSelecting = function(win = window) { + const selection = win.getSelection(); + let selecting = false; + try { + selecting = 'isCollapsed' in selection && !selection.isCollapsed; + } catch (e) { + // passthrough + } + return selecting; +}; +export default { + name: 'selection', + initialize(container) { + if (env('CONSUL_UI_DISABLE_ANCHOR_SELECTION')) { + return; + } + const dom = container.lookup('service:dom'); + const findAnchor = function(el) { + return el.tagName === 'A' ? el : dom.closest('a', el); + }; + const mousedown = function(e) { + const $a = findAnchor(e.target); + if ($a) { + if (typeof e.button !== 'undefined' && e.button === SECONDARY_BUTTON) { + const dataHref = $a.dataset.href; + if (dataHref) { + $a.setAttribute('href', dataHref); + } + return; + } + const href = $a.getAttribute('href'); + if (href) { + $a.dataset.href = href; + $a.removeAttribute('href'); + } + } + }; + const mouseup = function(e) { + const $a = findAnchor(e.target); + if ($a) { + const href = $a.dataset.href; + if (!isSelecting() && href) { + $a.setAttribute('href', href); + } + } + }; + + document.body.addEventListener('mousedown', mousedown); + document.body.addEventListener('mouseup', mouseup); + + container.reopen({ + willDestroy: function() { + document.body.removeEventListener('mousedown', mousedown); + document.body.removeEventListener('mouseup', mouseup); + return this._super(...arguments); + }, + }); + }, +}; diff --git a/ui-v2/app/utils/dom/click-first-anchor.js b/ui-v2/app/utils/dom/click-first-anchor.js index 0a8a6f23f..da3699fbc 100644 --- a/ui-v2/app/utils/dom/click-first-anchor.js +++ b/ui-v2/app/utils/dom/click-first-anchor.js @@ -1,9 +1,15 @@ -const clickEvent = function() { - return new MouseEvent('click', { - bubbles: true, - cancelable: true, - view: window, - }); +const clickEvent = function($el) { + ['mousedown', 'mouseup', 'click'] + .map(function(type) { + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + view: window, + }); + }) + .forEach(function(event) { + $el.dispatchEvent(event); + }); }; export default function(closest, click = clickEvent) { // TODO: Decide whether we should use `e` for ease @@ -24,7 +30,7 @@ export default function(closest, click = clickEvent) { // closest should probably be relaced with a finder function const $a = closest('tr', e.target).querySelector('a'); if ($a) { - $a.dispatchEvent(click()); + click($a); } }; } diff --git a/ui-v2/config/environment.js b/ui-v2/config/environment.js index aaeed171f..11ce0aef4 100644 --- a/ui-v2/config/environment.js +++ b/ui-v2/config/environment.js @@ -29,7 +29,10 @@ module.exports = function(environment) { }; // TODO: These should probably go onto APP ENV = Object.assign({}, ENV, { + // TODO: Let people alter this, as with anchor selection CONSUL_UI_DISABLE_REALTIME: false, + CONSUL_UI_DISABLE_ANCHOR_SELECTION: + typeof process.env.CONSUL_UI_DISABLE_ANCHOR_SELECTION !== 'undefined', CONSUL_GIT_SHA: (function() { if (process.env.CONSUL_GIT_SHA) { return process.env.CONSUL_GIT_SHA; diff --git a/ui-v2/tests/unit/utils/dom/click-first-anchor-test.js b/ui-v2/tests/unit/utils/dom/click-first-anchor-test.js index e8c64d662..2b01fef67 100644 --- a/ui-v2/tests/unit/utils/dom/click-first-anchor-test.js +++ b/ui-v2/tests/unit/utils/dom/click-first-anchor-test.js @@ -39,16 +39,16 @@ test("it does nothing if an anchor isn't found", function(assert) { }); assert.equal(actual, expected); }); -test('it dispatches the result of `click` if an anchor is found', function(assert) { - assert.expect(1); - const expected = 'click'; +test('it dispatches the result of `mouseup`, `mousedown`, `click` if an anchor is found', function(assert) { + assert.expect(3); + const expected = ['mousedown', 'mouseup', 'click']; const closest = function() { return { querySelector: function() { return { dispatchEvent: function(ev) { const actual = ev.type; - assert.equal(actual, expected); + assert.equal(actual, expected.shift()); }, }; },