From 5ba2bd66e065c599ada2eea20c62bdc98ce583a1 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Wed, 20 Mar 2019 14:04:06 +0000 Subject: [PATCH 1/4] Revert "Merge pull request #2855 from alphagov/revert-2843-stop-sticky-overlapping-focus" This reverts commit 0f9969989aaaf3853f1bd98562ab3b95c6c77668, reversing changes made to 42e3770e6544df53eb0572cab283a816a6665638. --- .../stick-to-window-when-scrolling.js | 152 +++++++++++++++++- app/assets/javascripts/templateFolderForm.js | 21 ++- app/templates/views/templates/choose.html | 1 + 3 files changed, 169 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/stick-to-window-when-scrolling.js b/app/assets/javascripts/stick-to-window-when-scrolling.js index 9eeaa65ed..b804fe5e9 100644 --- a/app/assets/javascripts/stick-to-window-when-scrolling.js +++ b/app/assets/javascripts/stick-to-window-when-scrolling.js @@ -5,6 +5,153 @@ var GOVUK = global.GOVUK || {}; var _mode = 'default'; + // Constructor to make objects representing the area sticky elements can scroll in + var ScrollArea = function (el, edge) { + 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.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); + }; + ScrollArea.prototype.removeEvents = function () { + this.node.removeEventListener('focus', this.focusHandler.bind(this)); + }; + ScrollArea.prototype.focusHandler = function (e) { + var $focusedElement = $(document.activeElement); + var endOfFurthestEl = focusOverlap.endOfFurthestEl(this._els, this.edge); + var overlap = focusOverlap.getOverlap($focusedElement, this.edge, endOfFurthestEl); + + if (overlap > 0) { + $(window).scrollTop($(window).scrollTop() + 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) { + var scrollArea = this.getAreaForEl(el); + + if (!scrollArea) { + this._scrollAreas.push(new ScrollArea(el, edge)); + } 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 ($focusedElement, edge, endOfFurthestEl) { + var topOfFocusedElement = $focusedElement.offset().top; + + if (!endOfFurthestEl) { return 0; } + + if (edge === 'top') { + return endOfFurthestEl - topOfFocusedElement; + } else { + return (topOfFocusedElement + $focusedElement.outerHeight()) - 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; + }); + } + }; + // 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 +523,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 +538,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 +606,7 @@ if (!exists) { elObj = new StickyElement($el, self); + scrollAreas.addEl(elObj, self.edge); } self.setElementDimensions(elObj, onDimensionsSet); diff --git a/app/assets/javascripts/templateFolderForm.js b/app/assets/javascripts/templateFolderForm.js index 0db522984..6aa8f6f74 100644 --- a/app/assets/javascripts/templateFolderForm.js +++ b/app/assets/javascripts/templateFolderForm.js @@ -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); diff --git a/app/templates/views/templates/choose.html b/app/templates/views/templates/choose.html index ee4cc105d..4f999bb23 100644 --- a/app/templates/views/templates/choose.html +++ b/app/templates/views/templates/choose.html @@ -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} ) %} From d1c028e7d4a4ddb743be059dba47ee24ae6e8dba Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Thu, 21 Mar 2019 16:51:27 +0000 Subject: [PATCH 2/4] Add library for tracking carets in textareas --- gulpfile.babel.js | 3 ++- package.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 9c4100cb4..1e68636df 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -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')) diff --git a/package.json b/package.json index 41b06c707..3e81dc120 100644 --- a/package.json +++ b/package.json @@ -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": { From 8ad4c5e6e1ab7b442d5c81157e8cb39b190c8ea1 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Thu, 21 Mar 2019 16:51:52 +0000 Subject: [PATCH 3/4] Add separate overlap handling for textareas Our textareas are multi-line and can change in size based on their content. Because of this, we need to check the caret for overlapping, not the whole textarea. This adds separate tracking for this. --- .../stick-to-window-when-scrolling.js | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/stick-to-window-when-scrolling.js b/app/assets/javascripts/stick-to-window-when-scrolling.js index b804fe5e9..5825c47ea 100644 --- a/app/assets/javascripts/stick-to-window-when-scrolling.js +++ b/app/assets/javascripts/stick-to-window-when-scrolling.js @@ -29,17 +29,55 @@ }; 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 overlap = focusOverlap.getOverlap($focusedElement, this.edge, endOfFurthestEl); + 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 { + focused = this.getFocusedDetails.forElement($focusedElement); + } + + overlap = focusOverlap.getOverlap(focused, this.edge, endOfFurthestEl); if (overlap > 0) { - $(window).scrollTop($(window).scrollTop() + overlap); + focusOverlap.adjustForOverlap(focused, this.edge, overlap); } }; ScrollArea.prototype.destroy = function () { @@ -116,15 +154,13 @@ // Object collecting together methods for stopping sticky overlapping focused elements var focusOverlap = { - getOverlap: function ($focusedElement, edge, endOfFurthestEl) { - var topOfFocusedElement = $focusedElement.offset().top; - + getOverlap: function (focused, edge, endOfFurthestEl) { if (!endOfFurthestEl) { return 0; } if (edge === 'top') { - return endOfFurthestEl - topOfFocusedElement; + return endOfFurthestEl - focused.top; } else { - return (topOfFocusedElement + $focusedElement.outerHeight()) - endOfFurthestEl; + return focused.bottom - endOfFurthestEl; } }, endOfFurthestEl: function (els, edge) { @@ -149,6 +185,16 @@ 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); + } } }; From a426cae968e92f8c4de76bb5369457de6a337c52 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Mon, 25 Mar 2019 15:16:51 +0000 Subject: [PATCH 4/4] Remove overlap check for elements in sticky Our sticky controls often contain focusable elements. --- .../javascripts/stick-to-window-when-scrolling.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/stick-to-window-when-scrolling.js b/app/assets/javascripts/stick-to-window-when-scrolling.js index 5825c47ea..7f2066aa5 100644 --- a/app/assets/javascripts/stick-to-window-when-scrolling.js +++ b/app/assets/javascripts/stick-to-window-when-scrolling.js @@ -6,7 +6,7 @@ var _mode = 'default'; // Constructor to make objects representing the area sticky elements can scroll in - var ScrollArea = function (el, edge) { + var ScrollArea = function (el, edge, selector) { var $el = el.$fixedEl; var $scrollArea = $el.closest('.sticky-scroll-area'); @@ -15,6 +15,7 @@ this._els = [el]; this.edge = edge; + this.selector = selector; this.node = scrollArea; this.setEvents(); }; @@ -64,6 +65,9 @@ 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; @@ -71,6 +75,7 @@ if (nodeName === 'textarea') { focused = this.getFocusedDetails.forCaret(e); } else { + if (isInSticky()) { return; } focused = this.getFocusedDetails.forElement($focusedElement); } @@ -105,11 +110,11 @@ return matches[0] || false; }, - addEl: function (el, edge) { + addEl: function (el, edge, selector) { var scrollArea = this.getAreaForEl(el); if (!scrollArea) { - this._scrollAreas.push(new ScrollArea(el, edge)); + this._scrollAreas.push(new ScrollArea(el, edge, selector)); } else { scrollArea.addEl(el); } @@ -652,7 +657,7 @@ if (!exists) { elObj = new StickyElement($el, self); - scrollAreas.addEl(elObj, self.edge); + scrollAreas.addEl(elObj, self.edge, self.CSS_SELECTOR); } self.setElementDimensions(elObj, onDimensionsSet);