diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b53409..863131c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Remove aria-hidden from search label to let assistive technologies see its accessible name - Use hidden attribute to show/hide expiry notices instead of just CSS - Only use dialog role for table of contents when it behaves like one (accessibility fix) +- Prevent interactive elements being obscured by sticky table of contents header ## 3.5.0 diff --git a/lib/assets/javascripts/_modules/table-of-contents.js b/lib/assets/javascripts/_modules/table-of-contents.js index b48e3503..850d7285 100644 --- a/lib/assets/javascripts/_modules/table-of-contents.js +++ b/lib/assets/javascripts/_modules/table-of-contents.js @@ -1,6 +1,39 @@ (function ($, Modules) { 'use strict' + // Most of the code below is gratefully taken from: + // https://www.tpgi.com/prevent-focused-elements-from-being-obscured-by-sticky-headers/ + var StickyOverlapMonitors = function ($sticky) { + this.$sticky = $sticky + this.offset = 0 + this.onFocus = this.showObscured.bind(this) + this.isMonitoring = false + } + StickyOverlapMonitors.prototype.run = function () { + var stickyIsVisible = this.$sticky.is(':visible') + if (stickyIsVisible && !this.isMonitoring) { + document.addEventListener('focus', this.onFocus, true) + this.isMonitoring = true + } + if (!stickyIsVisible && this.isMonitoring) { + document.removeEventListener('focus', this.onFocus, true) + this.isMonitoring = false + } + } + StickyOverlapMonitors.prototype.showObscured = function () { + var focused = document.activeElement || document.body + var applicable = focused !== document.body + + if (!applicable) { return } + + var stickyEdge = this.$sticky.get(0).getBoundingClientRect().bottom + this.offset + var diff = focused.getBoundingClientRect().top - stickyEdge + + if (diff < 0) { + $(window).scrollTop($(window).scrollTop() + diff) + } + } + Modules.TableOfContents = function () { var $html = $('html') @@ -10,6 +43,8 @@ var $openButton var $closeButton + var stickyOverlapMonitors + this.start = function ($element) { $toc = $element $tocList = $toc.find('.js-toc-list') @@ -19,6 +54,8 @@ fixRubberBandingInIOS() updateAriaAttributes() + stickyOverlapMonitors = new StickyOverlapMonitors($('.fixedsticky')) + stickyOverlapMonitors.run() // Need delegated handler for show link as sticky polyfill recreates element $openButton.on('click.toc', preventingScrolling(openNavigation)) @@ -27,7 +64,10 @@ // Allow aria hidden to be updated when resizing from mobile to desktop or // vice versa - $(window).on('resize.toc', updateAriaAttributes) + $(window).on('resize.toc', function () { + updateAriaAttributes() + stickyOverlapMonitors.run() + }) $(document).on('keydown.toc', function (event) { var ESC_KEY = 27 diff --git a/package.json b/package.json index 46c1bcd6..5a42330c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "it", "assert", "expect", + "Document", + "Element", + "Event", "GOVUK", "lunr", "$", diff --git a/spec/javascripts/table-of-contents-spec.js b/spec/javascripts/table-of-contents-spec.js index 5b97c6e8..0e398557 100644 --- a/spec/javascripts/table-of-contents-spec.js +++ b/spec/javascripts/table-of-contents-spec.js @@ -300,4 +300,138 @@ describe('Table of contents', function () { expect(scrollTopSpy).toHaveBeenCalledWith(399) }) }) + + describe('Prevent table of contents open button overlapping focused elements', function () { + var _getBoundingClientRect + var _addEventListener + var _scrollTop + var $link + var $tocStickyHeader + + beforeEach(function () { + _getBoundingClientRect = Element.prototype.getBoundingClientRect + _addEventListener = Document.prototype.addEventListener + _scrollTop = $.fn.scrollTop + + $tocStickyHeader = $('.toc-show') + $link = $('Test link') + $('body').append($link) + }) + + afterEach(function () { + Element.prototype.getBoundingClientRect = _getBoundingClientRect + Document.prototype.addEventListener = _addEventListener + $.fn.extend({ scrollTop: _scrollTop }) + + $link.remove() + }) + + it('if an element is focused while being overlaped by the table of contents sticky header, the screen should scroll to reveal it', function () { + var tocStickyHeaderBottomPos = 50 + var linkTopPos = 30 + var windowScrollPos = 300 + var scrollTopSpy = jasmine.createSpy('scrollTop') + + $html.addClass('mobile-size') // the open button only appears on mobile-size screens + + module = new GOVUK.Modules.TableOfContents() + module.start($toc) + + // stub DOM APIs used to work out if an element is overlaped + Element.prototype.getBoundingClientRect = function () { + if (this === $tocStickyHeader.get(0)) { + return { + bottom: tocStickyHeaderBottomPos + } + } + if (this === $link.get(0)) { + return { + top: linkTopPos + } + } + } + $.fn.scrollTop = function (yPos) { + if (this.get(0) !== window) { return _scrollTop(arguments) } + if (yPos === undefined) { // call for current scrollTop position + return windowScrollPos + } else { + scrollTopSpy(yPos) + } + } + + // replicating a real event requires us to focus the element and fire the event ourselves + $link.focus() + $link.get(0).dispatchEvent(new Event('focus')) + + expect(scrollTopSpy).toHaveBeenCalledWith(windowScrollPos - (tocStickyHeaderBottomPos - linkTopPos)) + + $html.removeClass('mobile-size') + }) + + it('if the table of contents sticky header isn\'t shown, no focus tracking should happen', function () { + var scrollTopSpy = jasmine.createSpy('scrollTop') + var getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRectSpy') + + module = new GOVUK.Modules.TableOfContents() + module.start($toc) + + // stub out web APIs used if focus tracking runs + Element.prototype.getBoundingClientRect = function () { + if ((this === $tocStickyHeader.get(0)) || (this === $link.get(0))) { + getBoundingClientRectSpy() + return { + bottom: 50, + top: 30 + } + } + } + $.fn.scrollTop = function (yPos) { + if (this.get(0) !== window) { return _scrollTop(arguments) } + scrollTopSpy(arguments) + } + + // replicating a real event requires us to focus the element and fire the event ourselves + $link.focus() + $link.get(0).dispatchEvent(new Event('focus')) + + expect(getBoundingClientRectSpy).not.toHaveBeenCalled() + expect(scrollTopSpy).not.toHaveBeenCalled() + }) + + it('if the table of contents sticky header shows but then is hidden when the screen resizes, no focus tracking should happen', function () { + var scrollTopSpy = jasmine.createSpy('scrollTop') + var getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRectSpy') + + $html.addClass('mobile-size') // the open button only appears on mobile-size screens + + module = new GOVUK.Modules.TableOfContents() + module.start($toc) + + // simulate screen resizing to desktop-size + $html.removeClass('mobile-size') + $(window).trigger('resize') + + // stub out web APIs used if focus tracking runs + Element.prototype.getBoundingClientRect = function () { + if ((this === $tocStickyHeader.get(0)) || (this === $link.get(0))) { + getBoundingClientRectSpy() + return { + bottom: 50, + top: 30 + } + } + } + $.fn.scrollTop = function (yPos) { + if (this.get(0) !== window) { return _scrollTop(arguments) } + scrollTopSpy(arguments) + } + + // replicating a real event requires us to focus the element and fire the event ourselves + $link.focus() + $link.get(0).dispatchEvent(new Event('focus')) + + expect(getBoundingClientRectSpy).not.toHaveBeenCalled() + expect(scrollTopSpy).not.toHaveBeenCalled() + }) + }) })