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
This commit is contained in:
John Cowen 2019-05-15 14:48:16 +01:00 committed by John Cowen
parent f9710c2c92
commit 466c3c6899
4 changed files with 82 additions and 11 deletions

View File

@ -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);
},
});
},
};

View File

@ -1,9 +1,15 @@
const clickEvent = function() { const clickEvent = function($el) {
return new MouseEvent('click', { ['mousedown', 'mouseup', 'click']
bubbles: true, .map(function(type) {
cancelable: true, return new MouseEvent(type, {
view: window, bubbles: true,
}); cancelable: true,
view: window,
});
})
.forEach(function(event) {
$el.dispatchEvent(event);
});
}; };
export default function(closest, click = clickEvent) { export default function(closest, click = clickEvent) {
// TODO: Decide whether we should use `e` for ease // 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 // closest should probably be relaced with a finder function
const $a = closest('tr', e.target).querySelector('a'); const $a = closest('tr', e.target).querySelector('a');
if ($a) { if ($a) {
$a.dispatchEvent(click()); click($a);
} }
}; };
} }

View File

@ -29,7 +29,10 @@ module.exports = function(environment) {
}; };
// TODO: These should probably go onto APP // TODO: These should probably go onto APP
ENV = Object.assign({}, ENV, { ENV = Object.assign({}, ENV, {
// TODO: Let people alter this, as with anchor selection
CONSUL_UI_DISABLE_REALTIME: false, CONSUL_UI_DISABLE_REALTIME: false,
CONSUL_UI_DISABLE_ANCHOR_SELECTION:
typeof process.env.CONSUL_UI_DISABLE_ANCHOR_SELECTION !== 'undefined',
CONSUL_GIT_SHA: (function() { CONSUL_GIT_SHA: (function() {
if (process.env.CONSUL_GIT_SHA) { if (process.env.CONSUL_GIT_SHA) {
return process.env.CONSUL_GIT_SHA; return process.env.CONSUL_GIT_SHA;

View File

@ -39,16 +39,16 @@ test("it does nothing if an anchor isn't found", function(assert) {
}); });
assert.equal(actual, expected); assert.equal(actual, expected);
}); });
test('it dispatches the result of `click` if an anchor is found', function(assert) { test('it dispatches the result of `mouseup`, `mousedown`, `click` if an anchor is found', function(assert) {
assert.expect(1); assert.expect(3);
const expected = 'click'; const expected = ['mousedown', 'mouseup', 'click'];
const closest = function() { const closest = function() {
return { return {
querySelector: function() { querySelector: function() {
return { return {
dispatchEvent: function(ev) { dispatchEvent: function(ev) {
const actual = ev.type; const actual = ev.type;
assert.equal(actual, expected); assert.equal(actual, expected.shift());
}, },
}; };
}, },