From d0aee7887bca0cf49ea8c86e4d4878191dda8bd7 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Thu, 14 Feb 2019 13:40:09 +0000 Subject: [PATCH 1/4] Allow setting of scroll area explicitly Defaults to scroll area being the parent element of the sticky element, if not set. --- app/assets/javascripts/stick-to-window-when-scrolling.js | 5 ++++- app/templates/views/templates/choose.html | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/stick-to-window-when-scrolling.js b/app/assets/javascripts/stick-to-window-when-scrolling.js index 9eeaa65ed..33dcc4b80 100644 --- a/app/assets/javascripts/stick-to-window-when-scrolling.js +++ b/app/assets/javascripts/stick-to-window-when-scrolling.js @@ -42,8 +42,11 @@ // Constructor for objects holding data for each element to have sticky behaviour var StickyElement = function ($el, sticky) { + var $scrollArea = $el.closest('.sticky-scroll-area'); + this._sticky = sticky; this.$fixedEl = $el; + this.$scrollArea = $scrollArea.length ? $scrollArea : $el.parent(); this._initialFixedClass = 'content-fixed-onload'; this._fixedClass = 'content-fixed'; this._appliedClass = null; @@ -390,7 +393,7 @@ }; Sticky.prototype.setElWidth = function (el) { var $el = el.$fixedEl; - var width = $el.parent().width(); + var width = el.$scrollArea.width(); el.horizontalSpace = width; // if stuck, element won't inherit width from parent so set explicitly 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 b13bc158ac4f711e7c07853e630dcb15dca000c5 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Mon, 11 Mar 2019 16:18:46 +0000 Subject: [PATCH 2/4] Add object to manage scroll areas for stickys --- .../stick-to-window-when-scrolling.js | 98 ++++++++++++++++++- 1 file changed, 94 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 33dcc4b80..f00a3bc2a 100644 --- a/app/assets/javascripts/stick-to-window-when-scrolling.js +++ b/app/assets/javascripts/stick-to-window-when-scrolling.js @@ -5,6 +5,96 @@ 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; + }; + 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; + }; + + // 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 dealing with marking the edge of a sticky, or group of // sticky elements (as seen in dialog mode) var oppositeEdge = { @@ -42,11 +132,8 @@ // Constructor for objects holding data for each element to have sticky behaviour var StickyElement = function ($el, sticky) { - var $scrollArea = $el.closest('.sticky-scroll-area'); - this._sticky = sticky; this.$fixedEl = $el; - this.$scrollArea = $scrollArea.length ? $scrollArea : $el.parent(); this._initialFixedClass = 'content-fixed-onload'; this._fixedClass = 'content-fixed'; this._appliedClass = null; @@ -379,6 +466,7 @@ Sticky.prototype.recalculate = function () { var self = this; var onSyncComplete = function () { + scrollAreas.syncEls(self._els); self.setEvents(); if (_mode === 'dialog') { dialog.fitToHeight(self); @@ -393,7 +481,8 @@ }; Sticky.prototype.setElWidth = function (el) { var $el = el.$fixedEl; - var width = el.$scrollArea.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 @@ -460,6 +549,7 @@ if (!exists) { elObj = new StickyElement($el, self); + scrollAreas.addEl(elObj, self.edge); } self.setElementDimensions(elObj, onDimensionsSet); From b8c5ab5e388a40b2fd7da2b5950cf57490a3f4aa Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Mon, 11 Mar 2019 16:26:11 +0000 Subject: [PATCH 3/4] Handle overlaps in scroll areas on focus events When focus changes in scroll areas, check the current focused element isn't overlapped by sticky elements in the area. If there are overlaps, mimic what browsers do if focus moves outside the viewport and scroll to move the focused element into view. --- .../stick-to-window-when-scrolling.js | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/app/assets/javascripts/stick-to-window-when-scrolling.js b/app/assets/javascripts/stick-to-window-when-scrolling.js index f00a3bc2a..b804fe5e9 100644 --- a/app/assets/javascripts/stick-to-window-when-scrolling.js +++ b/app/assets/javascripts/stick-to-window-when-scrolling.js @@ -16,6 +16,7 @@ this._els = [el]; this.edge = edge; this.node = scrollArea; + this.setEvents(); }; ScrollArea.prototype.addEl = function (el) { this._els.push(el); @@ -26,6 +27,24 @@ 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 = { @@ -95,6 +114,44 @@ } }; + // 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 = { From 3b0fd4a92c8e3c6d98cafb16519f18e3ac9d1649 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Mon, 11 Mar 2019 14:36:10 +0000 Subject: [PATCH 4/4] Make controls re-render only when state changed Adds a guard around all calls to `.render()` after the first one (which sets the initial state) to prevent DOM manipulation when not needed. The original behaviour meant the action buttons were re-rendered when items from the list of templates/folders were selected/deselected, even if the state hadn't changed. This meant, in some cases, focus was shifted to the buttons when you were still selecting/deselecting. --- app/assets/javascripts/templateFolderForm.js | 21 ++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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);