From b3e76ac9474eba91840df92e2be5e65ba3a08550 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 17 Sep 2024 09:42:25 +0100 Subject: [PATCH 1/2] Add JS to prevent focused elements being obscured The table of contents header, which you see on smaller screens, is sticky and so can overlap elements when they are focused because the browser will only scroll to reveal elements when they are off-screen. As far as the browser knows, the header and the focused element are both onscreen. This takes an approach from the paciello group where we listen for focus events, check if the header overlaps the element in focus and scrolls to reveal it if so. --- .../javascripts/_modules/table-of-contents.js | 42 +++++- package.json | 3 + spec/javascripts/table-of-contents-spec.js | 134 ++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) 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() + }) + }) }) From 5da1476724b63da210afcda271aad0cbd05bf94b Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Wed, 18 Sep 2024 14:04:18 +0100 Subject: [PATCH 2/2] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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