Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stop table of contents sticky header overlapping focused items #360

Merged
merged 2 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
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
134 changes: 134 additions & 0 deletions spec/javascripts/table-of-contents-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = $('<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()
})
})
})