Skip to content

Commit 56da6ee

Browse files
authored
Merge pull request #360 from alphagov/fix-toc-button-overlapping-focused-items
Stop table of contents sticky header overlapping focused items
2 parents 13ada11 + 5da1476 commit 56da6ee

File tree

4 files changed

+179
-1
lines changed

4 files changed

+179
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Remove aria-hidden from search label to let assistive technologies see its accessible name
99
- Use hidden attribute to show/hide expiry notices instead of just CSS
1010
- Only use dialog role for table of contents when it behaves like one (accessibility fix)
11+
- Prevent interactive elements being obscured by sticky table of contents header
1112

1213
## 3.5.0
1314

lib/assets/javascripts/_modules/table-of-contents.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
11
(function ($, Modules) {
22
'use strict'
33

4+
// Most of the code below is gratefully taken from:
5+
// https://www.tpgi.com/prevent-focused-elements-from-being-obscured-by-sticky-headers/
6+
var StickyOverlapMonitors = function ($sticky) {
7+
this.$sticky = $sticky
8+
this.offset = 0
9+
this.onFocus = this.showObscured.bind(this)
10+
this.isMonitoring = false
11+
}
12+
StickyOverlapMonitors.prototype.run = function () {
13+
var stickyIsVisible = this.$sticky.is(':visible')
14+
if (stickyIsVisible && !this.isMonitoring) {
15+
document.addEventListener('focus', this.onFocus, true)
16+
this.isMonitoring = true
17+
}
18+
if (!stickyIsVisible && this.isMonitoring) {
19+
document.removeEventListener('focus', this.onFocus, true)
20+
this.isMonitoring = false
21+
}
22+
}
23+
StickyOverlapMonitors.prototype.showObscured = function () {
24+
var focused = document.activeElement || document.body
25+
var applicable = focused !== document.body
26+
27+
if (!applicable) { return }
28+
29+
var stickyEdge = this.$sticky.get(0).getBoundingClientRect().bottom + this.offset
30+
var diff = focused.getBoundingClientRect().top - stickyEdge
31+
32+
if (diff < 0) {
33+
$(window).scrollTop($(window).scrollTop() + diff)
34+
}
35+
}
36+
437
Modules.TableOfContents = function () {
538
var $html = $('html')
639

@@ -10,6 +43,8 @@
1043
var $openButton
1144
var $closeButton
1245

46+
var stickyOverlapMonitors
47+
1348
this.start = function ($element) {
1449
$toc = $element
1550
$tocList = $toc.find('.js-toc-list')
@@ -19,6 +54,8 @@
1954

2055
fixRubberBandingInIOS()
2156
updateAriaAttributes()
57+
stickyOverlapMonitors = new StickyOverlapMonitors($('.fixedsticky'))
58+
stickyOverlapMonitors.run()
2259

2360
// Need delegated handler for show link as sticky polyfill recreates element
2461
$openButton.on('click.toc', preventingScrolling(openNavigation))
@@ -27,7 +64,10 @@
2764

2865
// Allow aria hidden to be updated when resizing from mobile to desktop or
2966
// vice versa
30-
$(window).on('resize.toc', updateAriaAttributes)
67+
$(window).on('resize.toc', function () {
68+
updateAriaAttributes()
69+
stickyOverlapMonitors.run()
70+
})
3171

3272
$(document).on('keydown.toc', function (event) {
3373
var ESC_KEY = 27

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"it",
2424
"assert",
2525
"expect",
26+
"Document",
27+
"Element",
28+
"Event",
2629
"GOVUK",
2730
"lunr",
2831
"$",

spec/javascripts/table-of-contents-spec.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,138 @@ describe('Table of contents', function () {
300300
expect(scrollTopSpy).toHaveBeenCalledWith(399)
301301
})
302302
})
303+
304+
describe('Prevent table of contents open button overlapping focused elements', function () {
305+
var _getBoundingClientRect
306+
var _addEventListener
307+
var _scrollTop
308+
var $link
309+
var $tocStickyHeader
310+
311+
beforeEach(function () {
312+
_getBoundingClientRect = Element.prototype.getBoundingClientRect
313+
_addEventListener = Document.prototype.addEventListener
314+
_scrollTop = $.fn.scrollTop
315+
316+
$tocStickyHeader = $('.toc-show')
317+
$link = $('<a href="">Test link</a>')
318+
$('body').append($link)
319+
})
320+
321+
afterEach(function () {
322+
Element.prototype.getBoundingClientRect = _getBoundingClientRect
323+
Document.prototype.addEventListener = _addEventListener
324+
$.fn.extend({ scrollTop: _scrollTop })
325+
326+
$link.remove()
327+
})
328+
329+
it('if an element is focused while being overlaped by the table of contents sticky header, the screen should scroll to reveal it', function () {
330+
var tocStickyHeaderBottomPos = 50
331+
var linkTopPos = 30
332+
var windowScrollPos = 300
333+
var scrollTopSpy = jasmine.createSpy('scrollTop')
334+
335+
$html.addClass('mobile-size') // the open button only appears on mobile-size screens
336+
337+
module = new GOVUK.Modules.TableOfContents()
338+
module.start($toc)
339+
340+
// stub DOM APIs used to work out if an element is overlaped
341+
Element.prototype.getBoundingClientRect = function () {
342+
if (this === $tocStickyHeader.get(0)) {
343+
return {
344+
bottom: tocStickyHeaderBottomPos
345+
}
346+
}
347+
if (this === $link.get(0)) {
348+
return {
349+
top: linkTopPos
350+
}
351+
}
352+
}
353+
$.fn.scrollTop = function (yPos) {
354+
if (this.get(0) !== window) { return _scrollTop(arguments) }
355+
if (yPos === undefined) { // call for current scrollTop position
356+
return windowScrollPos
357+
} else {
358+
scrollTopSpy(yPos)
359+
}
360+
}
361+
362+
// replicating a real event requires us to focus the element and fire the event ourselves
363+
$link.focus()
364+
$link.get(0).dispatchEvent(new Event('focus'))
365+
366+
expect(scrollTopSpy).toHaveBeenCalledWith(windowScrollPos - (tocStickyHeaderBottomPos - linkTopPos))
367+
368+
$html.removeClass('mobile-size')
369+
})
370+
371+
it('if the table of contents sticky header isn\'t shown, no focus tracking should happen', function () {
372+
var scrollTopSpy = jasmine.createSpy('scrollTop')
373+
var getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRectSpy')
374+
375+
module = new GOVUK.Modules.TableOfContents()
376+
module.start($toc)
377+
378+
// stub out web APIs used if focus tracking runs
379+
Element.prototype.getBoundingClientRect = function () {
380+
if ((this === $tocStickyHeader.get(0)) || (this === $link.get(0))) {
381+
getBoundingClientRectSpy()
382+
return {
383+
bottom: 50,
384+
top: 30
385+
}
386+
}
387+
}
388+
$.fn.scrollTop = function (yPos) {
389+
if (this.get(0) !== window) { return _scrollTop(arguments) }
390+
scrollTopSpy(arguments)
391+
}
392+
393+
// replicating a real event requires us to focus the element and fire the event ourselves
394+
$link.focus()
395+
$link.get(0).dispatchEvent(new Event('focus'))
396+
397+
expect(getBoundingClientRectSpy).not.toHaveBeenCalled()
398+
expect(scrollTopSpy).not.toHaveBeenCalled()
399+
})
400+
401+
it('if the table of contents sticky header shows but then is hidden when the screen resizes, no focus tracking should happen', function () {
402+
var scrollTopSpy = jasmine.createSpy('scrollTop')
403+
var getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRectSpy')
404+
405+
$html.addClass('mobile-size') // the open button only appears on mobile-size screens
406+
407+
module = new GOVUK.Modules.TableOfContents()
408+
module.start($toc)
409+
410+
// simulate screen resizing to desktop-size
411+
$html.removeClass('mobile-size')
412+
$(window).trigger('resize')
413+
414+
// stub out web APIs used if focus tracking runs
415+
Element.prototype.getBoundingClientRect = function () {
416+
if ((this === $tocStickyHeader.get(0)) || (this === $link.get(0))) {
417+
getBoundingClientRectSpy()
418+
return {
419+
bottom: 50,
420+
top: 30
421+
}
422+
}
423+
}
424+
$.fn.scrollTop = function (yPos) {
425+
if (this.get(0) !== window) { return _scrollTop(arguments) }
426+
scrollTopSpy(arguments)
427+
}
428+
429+
// replicating a real event requires us to focus the element and fire the event ourselves
430+
$link.focus()
431+
$link.get(0).dispatchEvent(new Event('focus'))
432+
433+
expect(getBoundingClientRectSpy).not.toHaveBeenCalled()
434+
expect(scrollTopSpy).not.toHaveBeenCalled()
435+
})
436+
})
303437
})

0 commit comments

Comments
 (0)