Skip to content

Commit

Permalink
Fix checkbox/radio shared editor on Mac (#1459)
Browse files Browse the repository at this point in the history
Editor: wrap checkbox and radio button shared editors on Mac

In OS X form controls are not considered focusable elements. Some browsers
(Safari, Firefox) adopt this decision:
whatwg/html#4356
https://bugzilla.mozilla.org/show_bug.cgi?id=1524863
https://bugs.webkit.org/show_bug.cgi?id=22261
The resulting implementation is broken - you can call `focus()` on a checkbox
and it will be focused, but clicking on a checkbox does not focus it. Further,
clicking on a focused checkbox blurs it. To overcome these challenges
checkboxes and radios are wrapped in a div that can receive focus (and blur)
in a consistent manner.

Fixes #1458
  • Loading branch information
msssk authored Apr 27, 2020
1 parent bd8f5b9 commit d8d2a16
Show file tree
Hide file tree
Showing 2 changed files with 150 additions and 26 deletions.
169 changes: 143 additions & 26 deletions Editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ define([
'dojo/dom-construct',
'dojo/dom-class',
'dojo/on',
'dojo/has',
'dojo/query',
'./Grid',
'dojo/_base/sniff'
], function (declare, lang, Deferred, domConstruct, domClass, on, has, query, Grid) {
'dojo/sniff',
'./Grid'
], function (declare, lang, Deferred, domConstruct, domClass, on, query, has, Grid) {

return declare(null, {
editorFocusWrapperClassName: 'dgrid-editor-focus-wrapper',

constructor: function () {
this._editorInstances = {};
// Tracks shared editor dismissal listeners, and editor click/change listeners for old IE
Expand Down Expand Up @@ -60,7 +61,7 @@ define([

refresh: function () {
for (var id in this._editorInstances) {
var editorInstanceDomNode = this._editorInstances[id].domNode;
var editorInstanceDomNode = this._getEditorRootNode(this._editorInstances[id].domNode);
if (editorInstanceDomNode && editorInstanceDomNode.parentNode) {
// Remove any editor widgets from the DOM before List destroys it, to avoid issues in IE (#1100)
editorInstanceDomNode.parentNode.removeChild(editorInstanceDomNode);
Expand Down Expand Up @@ -269,9 +270,10 @@ define([
// focus / blur-handler-resume logic is surrounded in a setTimeout
// to play nice with Keyboard's dgrid-cellfocusin as an editOn event
self._editTimer = setTimeout(function () {
var focusNode = self._getEditorFocusNode(cmp);
// focus the newly-placed control (supported by form widgets and HTML inputs)
if (cmp.focus) {
cmp.focus();
if (focusNode.focus) {
focusNode.focus();
}
// resume blur handler once editor is focused
if (column._editorBlurHandle) {
Expand Down Expand Up @@ -308,10 +310,10 @@ define([

// In some browsers, moving a DOM node causes a blur event to fire which in this case,
// is a bad time for the blur handler to run. Blur the input node first.
var node = cmp.domNode || cmp;
if (node.offsetWidth) {
var focusNode = this._getEditorFocusNode(cmp);
if (focusNode.offsetWidth) {
// The editor is visible. Blur it.
node.blur();
focusNode.blur();
// In IE, the blur does not complete immediately.
// Push showing of the editor to the next turn.
// (dfd will be resolved within showEditor)
Expand Down Expand Up @@ -388,7 +390,7 @@ define([
cmp.reset();
}

cellElement.appendChild(cmp.domNode || cmp);
cellElement.appendChild(this._getEditorRootNode(cmp));

if (isWidget && !column.editOn) {
// Queue arguments to be run once editor is in DOM
Expand Down Expand Up @@ -452,6 +454,86 @@ define([
}
},

// summary:
// Get the focus node of an editor component. For a wrapped node, the focus node will be the wrapper.
// For a non-wrapped widget, the focus node will be widget.focusNode OR widget.domNode.
// For a non-wrapped input element, the focus node will be the input element
// cmp:
// editor component which can be:
// 1. an HTMLInputElement
// 2. a Dijit widget instance
// returns:
// HTMLElement
_getEditorFocusNode: function (cmp) {
var focusNode = cmp.parentNode || (cmp.domNode && cmp.domNode.parentNode);

if (!focusNode || !domClass.contains(focusNode, this.editorFocusWrapperClassName)) {
focusNode = cmp.focusNode || cmp.domNode || cmp;
}

return focusNode;
},

// summary:
// Get the editor component's root node, which may be the wrapper node
// cmp:
// editor component which can be:
// 1. an HTMLInputElement
// 2. a Dijit widget instance
// returns:
// Wrapper node OR widget.domNode OR HTMLInputElement
_getEditorRootNode: function (cmp) {
if (!cmp) {
return;
}

var rootNode = cmp.parentNode || (cmp.domNode && cmp.domNode.parentNode);

if (!rootNode || !domClass.contains(rootNode, this.editorFocusWrapperClassName)) {
rootNode = cmp.domNode || cmp;
}

return rootNode;
},

// In OS X form controls are not considered focusable elements. Some browsers (Safari, Firefox)
// adopt this decision:
// https://github.com/whatwg/html/issues/4356
// https://bugzilla.mozilla.org/show_bug.cgi?id=1524863
// https://bugs.webkit.org/show_bug.cgi?id=22261
// The resulting implementation is broken - you can call `focus()` on a checkbox and it
// will be focused, but clicking on a checkbox does not focus it. Further, clicking on a focused
// checkbox blurs it. To overcome these challenges checkboxes and radios are wrapped in a div
// that can receive focus (and blur) in a consistent manner.
//
// summary:
// Create a focus wrapper for an editor component
// node:
// the node to wrap, either a plain HTMLInputElement or the root node of a widget (widget.domNode)
// tabIndex:
// [optional] tabIndex value to set on the wrapper node, defaults to the node's tabIndex or -1
// returns:
// HTMLDivElement that has `node` as its only child
_createEditorFocusWrapper: function (node, tabIndex) {
if (isNaN(tabIndex)) {
if (isNaN(node.tabIndex)) {
tabIndex = -1;
}
else {
tabIndex = node.tabIndex;
}
}

var wrapper = domConstruct.create('div', {
className: this.editorFocusWrapperClassName,
tabIndex: tabIndex
});

wrapper.appendChild(node);

return wrapper;
},

_createEditor: function (column, object) {
// Creates an editor instance based on column definition properties,
// and hooks up events.
Expand All @@ -463,7 +545,8 @@ define([
cmp,
node,
tagName,
tagArgs = {};
tagArgs = {},
wrapperNode;

args = column.editorArgs || {};
if (typeof args === 'function') {
Expand All @@ -479,13 +562,26 @@ define([
// Add dgrid-input to className to make consistent with HTML inputs.
node.className += ' dgrid-input';

// For editOn editors, connect to onBlur rather than onChange, since
// the latter is delayed by setTimeouts in Dijit and will fire too late.
cmp.on(editOn ? 'blur' : 'change', function () {
if (!cmp._dgridIgnoreChange) {
self._updatePropertyFromEditor(column, cmp, {type: 'widget'});
}
});
if (has('mac') && column.editOn && /checkbox|radio/i.test(node.type)) {
wrapperNode = this._createEditorFocusWrapper(cmp.domNode, column.tabIndex);

this._listeners.push(
on(wrapperNode, 'blur', function () {
if (!cmp._dgridIgnoreChange) {
self._updatePropertyFromEditor(column, cmp, {type: 'widget'});
}
})
);
}
else {
// For editOn editors, connect to onBlur rather than onChange, since
// the latter is delayed by setTimeouts in Dijit and will fire too late.
cmp.on(editOn ? 'blur' : 'change', function () {
if (!cmp._dgridIgnoreChange) {
self._updatePropertyFromEditor(column, cmp, {type: 'widget'});
}
});
}
}
else {
// considerations for standard HTML form elements
Expand All @@ -511,6 +607,12 @@ define([
tabIndex: isNaN(column.tabIndex) ? -1 : column.tabIndex
}, args));

if (has('mac') && column.editOn && /checkbox|radio/i.test(editor)) {
wrapperNode = this._createEditorFocusWrapper(cmp);
cmp.tabIndex = 0;
cmp.removeAttribute('tabindex');
}

if (has('ie') < 9) {
// IE<9 doesn't fire change events for all the right things,
// and it doesn't bubble.
Expand Down Expand Up @@ -565,8 +667,8 @@ define([
var cmp = this._createEditor(column),
self = this,
isWidget = cmp.domNode,
node = cmp.domNode || cmp,
focusNode = cmp.focusNode || node,
rootNode = this._getEditorRootNode(cmp),
focusNode = this._getEditorFocusNode(cmp),
reset = isWidget ?
function () {
cmp.set('value', cmp._dgridLastValue);
Expand All @@ -592,10 +694,25 @@ define([
}
}

function onblur() {
var parentNode = node.parentNode,
function onblur(event) {
var wrapperNode;

if (event && event.target) {
wrapperNode = event.target;
wrapperNode = domClass.contains(wrapperNode, self.editorFocusWrapperClassName) && wrapperNode;

// Some browsers on OS X emit a blur event when a focused checkbox is clicked
// Revert the erroneous blur by refocusing the wrapper and exit
// (see notes above on the _createEditorFocusWrapper method)
if (wrapperNode && event.relatedTarget === (cmp.focusNode || cmp)) {
wrapperNode.focus();
return;
}
}

var parentNode = rootNode.parentNode,
options = { alreadyHooked: true },
cell = self.cell(node);
cell = self.cell(rootNode);

// emit an event immediately prior to removing an editOn editor
on.emit(cell.element, 'dgrid-editor-hide', {
Expand All @@ -608,7 +725,7 @@ define([
});
column._editorBlurHandle.pause();
// Remove the editor from the cell, to be reused later.
parentNode.removeChild(node);
parentNode.removeChild(rootNode);

if (cell.row) {
// If the row is still present (i.e. we didn't blur due to removal),
Expand Down Expand Up @@ -646,7 +763,7 @@ define([
this._editorColumnListeners.push(on(focusNode, 'keydown', dismissOnKey));

// hook up blur handler, but don't activate until widget is activated
(column._editorBlurHandle = on.pausable(cmp, 'blur', onblur)).pause();
(column._editorBlurHandle = on.pausable(this._getEditorFocusNode(cmp), 'blur', onblur)).pause();
this._editorColumnListeners.push(column._editorBlurHandle);

return cmp;
Expand Down
7 changes: 7 additions & 0 deletions test/Editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@
}
}
},
{
label: 'dijit/form/RadioButton',
generate: generateBool,
column: {
editorArgs: { value: "true" }
}
},
{
label: "dijit/form/ValidationTextBox",
generate: generateText,
Expand Down

0 comments on commit d8d2a16

Please sign in to comment.