Skip to content

Commit

Permalink
Add JS to prevent focused elements being obscured
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
tombye committed Sep 17, 2024
1 parent 1b9a255 commit b9e3172
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 7 deletions.
42 changes: 41 additions & 1 deletion lib/assets/javascripts/_modules/table-of-contents.js
Original file line number Diff line number Diff line change
@@ -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')

Expand All @@ -10,6 +43,8 @@
var $openButton
var $closeButton

var stickyOverlapMonitors

this.start = function ($element) {
$toc = $element
$tocList = $toc.find('.js-toc-list')
Expand All @@ -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))
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"it",
"assert",
"expect",
"Document",
"Element",
"Event",
"GOVUK",
"lunr",
"$",
Expand Down
141 changes: 135 additions & 6 deletions spec/javascripts/table-of-contents-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,9 @@ describe('Table of contents', function () {
})

it("the table of contents should have a role of 'dialog'", function () {

$(window).trigger('resize')

expect($toc.attr('role')).toEqual('dialog')

})

it('if the table of contents is closed, it should mark the buttons as not expanded', function () {
Expand All @@ -140,17 +138,14 @@ describe('Table of contents', function () {
})
})

it("on a desktop-size screen, the table of contents should have no role", function () {

it('on a desktop-size screen, the table of contents should have no role', function () {
module = new GOVUK.Modules.TableOfContents()
module.start($toc)

$(window).trigger('resize')

expect($toc.attr('role')).toEqual(undefined)

})

})

describe('if the open button is clicked', function () {
Expand Down Expand Up @@ -305,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 = $('<a href="">Test link</a>')
$('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()
})
})
})

0 comments on commit b9e3172

Please sign in to comment.