Skip to content

Commit

Permalink
Merge branch 'main' into stop-images-with-no-alt-being-wrapped-in-links
Browse files Browse the repository at this point in the history
  • Loading branch information
tombye authored Oct 1, 2024
2 parents b7a8041 + 56da6ee commit cb0fc35
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 1 deletion.
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
- Only wrap images with alt text in hyperlinks

## 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()
})
})
})

0 comments on commit cb0fc35

Please sign in to comment.