Merge pull request #2876 from alphagov/stop-focus-overlap-alternative

Stop focus overlap - with support for caret focus
This commit is contained in:
Tom Byers
2019-03-27 14:54:09 +00:00
committed by GitHub
5 changed files with 223 additions and 6 deletions

View File

@@ -5,6 +5,204 @@
var GOVUK = global.GOVUK || {};
var _mode = 'default';
// Constructor to make objects representing the area sticky elements can scroll in
var ScrollArea = function (el, edge, selector) {
var $el = el.$fixedEl;
var $scrollArea = $el.closest('.sticky-scroll-area');
$scrollArea = $scrollArea.length ? $scrollArea : $el.parent();
scrollArea = $scrollArea.get(0);
this._els = [el];
this.edge = edge;
this.selector = selector;
this.node = scrollArea;
this.setEvents();
};
ScrollArea.prototype.addEl = function (el) {
this._els.push(el);
};
ScrollArea.prototype.hasEl = function (el) {
return $.inArray(el, this._els) !== -1;
};
ScrollArea.prototype.updateEls = function (usedEls) {
this._els = usedEls;
};
ScrollArea.prototype.setEvents = function () {
this.node.addEventListener('focus', this.focusHandler.bind(this), true);
$(this.node).on('keyup', 'textarea', this.focusHandler.bind(this));
};
ScrollArea.prototype.removeEvents = function () {
this.node.removeEventListener('focus', this.focusHandler.bind(this));
$(this.node).find('textarea').off('keyup', 'textarea', this.focusHandler.bind(this));
};
ScrollArea.prototype.getFocusedDetails = {
forElement: function ($focusedElement) {
focused = {
'top': $focusedElement.offset().top,
'height': $focusedElement.outerHeight(),
'type': 'element'
};
focused.bottom = focused.top + focused.height;
return focused;
},
forCaret: function (evt) {
var textarea = evt.target;
var caretCoordinates = window.getCaretCoordinates(textarea, textarea.selectionEnd);
var focused = {
'top': $(textarea).offset().top + caretCoordinates.top,
'height': caretCoordinates.height,
'type': 'caret'
};
focused.bottom = focused.top + focused.height;
return focused;
}
};
ScrollArea.prototype.focusHandler = function (e) {
var $focusedElement = $(document.activeElement);
var nodeName = $focusedElement.get(0).nodeName.toLowerCase();
var endOfFurthestEl = focusOverlap.endOfFurthestEl(this._els, this.edge);
var isInSticky = function () {
return $focusedElement.closest(this.selector).length > 0;
}.bind(this);
var focused;
var overlap;
// if textarea is focused, we care about checking the caret, not the whole element
if (nodeName === 'textarea') {
focused = this.getFocusedDetails.forCaret(e);
} else {
if (isInSticky()) { return; }
focused = this.getFocusedDetails.forElement($focusedElement);
}
overlap = focusOverlap.getOverlap(focused, this.edge, endOfFurthestEl);
if (overlap > 0) {
focusOverlap.adjustForOverlap(focused, this.edge, overlap);
}
};
ScrollArea.prototype.destroy = function () {
this.removeEvents();
};
// Object collecting together methods for interacting with scrollareas
var scrollAreas = {
_scrollAreas: [],
getAreaForEl: function (el) {
var loopIdx = this._scrollAreas.length;
while(loopIdx--) {
if (this._scrollAreas[loopIdx].hasEl(el)) {
return this._scrollAreas[loopIdx];
}
}
return false;
},
getAreaByEl: function (el) {
var matches = $.grep(this._scrollAreas, function (area) {
return $.inArray(el, area.els) !== -1;
});
return matches[0] || false;
},
addEl: function (el, edge, selector) {
var scrollArea = this.getAreaForEl(el);
if (!scrollArea) {
this._scrollAreas.push(new ScrollArea(el, edge, selector));
} else {
scrollArea.addEl(el);
}
},
syncEls: function (elsInDOM) {
var self = this;
var unusedAreas = [];
var getUsed = function (area) {
var used = [];
$.each(elsInDOM, function (elIdx, el) {
if (area.hasEl(el)) {
used.push(el);
}
});
return used;
};
var deleteUnused = function (idx, areaIdx) {
// remove any events for overlap checking bound to the scrollArea
self._scrollAreas[areaIdx].destroy();
self._scrollAreas.splice(areaIdx, 1);
};
// update any scroll areas with els still in the DOM and track any with none
$.each(this._scrollAreas, function (areaIdx, area) {
var used = getUsed(area);
if (!used.length) {
unusedAreas.push(areaIdx);
}
area.updateEls(used);
});
// delete any scroll areas with no els still in DOM
$.each(unusedAreas, deleteUnused);
}
};
// Object collecting together methods for stopping sticky overlapping focused elements
var focusOverlap = {
getOverlap: function (focused, edge, endOfFurthestEl) {
if (!endOfFurthestEl) { return 0; }
if (edge === 'top') {
return endOfFurthestEl - focused.top;
} else {
return focused.bottom - endOfFurthestEl;
}
},
endOfFurthestEl: function (els, edge) {
var stuckEls = $.grep(els, function (el) { return el.isStuck(); });
var edgeOfEl;
var offsets;
if (edge === 'bottom') {
edgeOfEl = function (el) {
return el.$fixedEl.offset().top;
};
} else {
edgeOfEl = function (el) {
return el.$fixedEl.offset().top + el.height;
};
}
if (!stuckEls.length) { return false; }
offsets = $.map(stuckEls, function (el) { return edgeOfEl(el); });
return offsets.reduce(function (accumulator, offset) {
return (accumulator < offset) ? offset: accumulator;
});
},
adjustForOverlap: function (focused, edge, overlap) {
var scrollTop = $(window).scrollTop();
// scroll so element becomes visible
if (edge === 'top') {
$(window).scrollTop(scrollTop - overlap);
} else {
$(window).scrollTop(scrollTop + overlap);
}
}
};
// Object collecting together methods for dealing with marking the edge of a sticky, or group of
// sticky elements (as seen in dialog mode)
var oppositeEdge = {
@@ -376,6 +574,7 @@
Sticky.prototype.recalculate = function () {
var self = this;
var onSyncComplete = function () {
scrollAreas.syncEls(self._els);
self.setEvents();
if (_mode === 'dialog') {
dialog.fitToHeight(self);
@@ -390,7 +589,8 @@
};
Sticky.prototype.setElWidth = function (el) {
var $el = el.$fixedEl;
var width = $el.parent().width();
var scrollArea = scrollAreas.getAreaByEl(el);
var width = $(scrollArea.node).width();
el.horizontalSpace = width;
// if stuck, element won't inherit width from parent so set explicitly
@@ -457,6 +657,7 @@
if (!exists) {
elObj = new StickyElement($el, self);
scrollAreas.addEl(elObj, self.edge, self.CSS_SELECTOR);
}
self.setElementDimensions(elObj, onDimensionsSet);

View File

@@ -33,10 +33,11 @@
this.activateStickyElements();
// first off show the new template / new folder buttons
this.currentState = this.$form.data('prev-state') || 'unknown';
if (this.currentState === 'unknown') {
this._lastState = this.$form.data('prev-state');
if (this._lastState === undefined) {
this.selectActionButtons();
} else {
this.currentState = this._lastState;
this.render();
}
@@ -144,11 +145,21 @@
}
};
// method that checks the state against the last one, used prior to render() to see if needed
this.stateChanged = function() {
let changed = this.currentState !== this._lastState;
this._lastState = this.currentState;
return changed;
};
this.actionButtonClicked = function(event) {
event.preventDefault();
this.currentState = $(event.currentTarget).val();
this.render();
if (this.stateChanged()) {
this.render();
}
};
this.selectionStatus = {
@@ -173,7 +184,9 @@
this.currentState = 'nothing-selected-buttons';
}
this.render();
if (this.stateChanged()) {
this.render();
}
this.selectionStatus.update(numSelected);

View File

@@ -74,6 +74,7 @@
{% if current_user.has_permissions('manage_templates') %}
{% call form_wrapper(
class='sticky-scroll-area',
module='template-folder-form',
data_kwargs={'prev-state': templates_and_folders_form.op or None}
) %}

View File

@@ -88,7 +88,8 @@ gulp.task('javascripts', () => gulp
paths.npm + 'jquery/dist/jquery.min.js',
paths.npm + 'query-command-supported/dist/queryCommandSupported.min.js',
paths.npm + 'diff-dom/diffDOM.js',
paths.npm + 'timeago/jquery.timeago.js'
paths.npm + 'timeago/jquery.timeago.js',
paths.npm + 'textarea-caret/index.js'
]))
.pipe(plugins.uglify())
.pipe(plugins.concat('all.js'))

View File

@@ -36,6 +36,7 @@
"hogan": "1.0.2",
"jquery": "1.12.4",
"query-command-supported": "1.0.0",
"textarea-caret": "^3.1.0",
"timeago": "1.6.1"
},
"devDependencies": {