Skip to content
This repository has been archived by the owner on Mar 6, 2023. It is now read-only.

Highlight anchors in the page contents navigation, on scroll #61

Merged
merged 10 commits into from
Oct 27, 2016
6 changes: 6 additions & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
// from govuk_frontend_toolkit
//= require govuk/stick-at-top-when-scrolling
//= require govuk/stop-scrolling-at-footer
//= require_tree .

window.GOVUK.stickAtTopWhenScrolling.init();
window.GOVUK.stopScrollingAtFooter.addEl($('.js-stick-at-top-when-scrolling'));
150 changes: 150 additions & 0 deletions app/assets/javascripts/modules/highlight-active-section-heading.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
;(function (Modules, root) {
'use strict'

var $ = root.$
var $window = $(root)

Modules.HighlightActiveSectionHeading = function () {
var self = this
var _hasResized = true
var _hasScrolled = true
var _interval = 50
var anchorIDs = []

self.getWindowDimensions = function () {
return {
height: $window.height(),
width: $window.width()
}
}

self.getWindowPositions = function () {
return {
scrollTop: $window.scrollTop()
}
}

self.getElementOffset = function ($el) {
return $el.offset()
}

self.start = function ($el) {
$window.resize(self.hasResized)
$window.scroll(self.hasScrolled)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is totally personal preference, but I've moved to using e.g. .on('scroll', …) as I think it makes it a million times clearer that you're setting up a new binding, vs telling the window to resize or scroll itself.


setInterval(self.checkResize, _interval)
setInterval(self.checkScroll, _interval)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we're polling at intervals rather than binding directly to the scroll events? If it's to throttle/debounce the scroll event, have we ruled out using e.g. https://github.com/cowboy/jquery-throttle-debounce?

Either way, it'd be good to document why we're doing this as it seems a bit odd to me and difficult to follow…

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was following the example over here sticky-element-container.js, as it works in a similar way. If there's a better way to do this I am happy to amend.


self.$anchors = $el.find('.js-page-contents a')
self.getAnchors()

self.checkResize()
self.checkScroll()
}

self.hasResized = function () {
_hasResized = true
return _hasResized
}

self.hasScrolled = function () {
_hasScrolled = true
return _hasScrolled
}

self.checkResize = function () {
if (_hasResized) {
_hasResized = false
_hasScrolled = true
}
}

self.checkScroll = function () {
if (_hasScrolled) {
_hasScrolled = false
var windowDimensions = self.getWindowDimensions()
if ( windowDimensions.width <= 768) {
self.removeActiveItem()
} else {
self.updateActiveNavItem()
}
}
}

self.getAnchors = function () {
$.each(self.$anchors, function(i) {
var anchorID = $(this).attr('href')
// e.g. anchorIDs['#meeting-the-digital-service-standard', '#understand-your-users', '#research-continually']
anchorIDs.push(anchorID)
})
}

self.getHeadingPosition = function ($theID) {
return $theID.offset()
}

self.getNextHeadingPosition = function ($theNextID) {
return $theNextID.offset()
}

self.getFooterPosition = function ($theID) {
return $theID.offset()
}

self.getDistanceBetweenHeadings = function (headingPosition, nextHeadingPosition) {
var distanceBetweenHeadings = (nextHeadingPosition - headingPosition)
return distanceBetweenHeadings
}

self.updateActiveNavItem = function () {
var windowVerticalPosition = self.getWindowPositions().scrollTop
var footerPosition = self.getFooterPosition($('#footer'))

$.each(self.$anchors, function(i) {

var theID = anchorIDs[i]
var theNextID = anchorIDs[i + 1]

var $theID = $(theID)
var $theNextID = $(theNextID)

var headingPosition = self.getHeadingPosition($theID)

if (!headingPosition) {
return
}

headingPosition = headingPosition.top
headingPosition = headingPosition - 53 // fix the offset from top of page

if (theNextID) {
var nextHeadingPosition = self.getNextHeadingPosition($theNextID).top
}

var distanceBetweenHeadings = self.getDistanceBetweenHeadings(headingPosition, nextHeadingPosition)
if (distanceBetweenHeadings) {
var isPastHeading = (windowVerticalPosition >= headingPosition && windowVerticalPosition < (headingPosition + distanceBetweenHeadings))
}
// when distanceBetweenHeadings is false (as there isn't a next heading)
else {
var isPastHeading = (windowVerticalPosition >= headingPosition && windowVerticalPosition < footerPosition.top)
}

if (isPastHeading) {
self.setActiveItem(theID)
}

})

}

self.setActiveItem = function (theID) {
self.$anchors.removeClass('active')
self.$anchors.filter("[href='" + theID + "']").addClass('active')
}

self.removeActiveItem = function () {
self.$anchors.removeClass('active')
}
}
})(window.GOVUK.Modules, window)
17 changes: 17 additions & 0 deletions app/assets/stylesheets/modules/_govspeak-wrapper.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,21 @@
@include media(tablet) {
padding-top: 1.875em;
}

.govuk-govspeak p,
.govuk-govspeak ol,
.govuk-govspeak ul {
// govspeak margin bottom is 20px
margin-bottom: $gutter-half;
}

.govuk-govspeak h2 {
margin-top: 15px;
padding-top: 15px;
// govspeak margin-top is 45px
@include media(tablet) {
margin-top: 15px;
padding-top: 30px;
}
}
}
5 changes: 5 additions & 0 deletions app/assets/stylesheets/modules/_page-contents.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

@include media(tablet) {
margin-top: 2em;
padding-bottom: 4em;
}

&__title {
Expand Down Expand Up @@ -35,4 +36,8 @@
text-decoration: underline;
}
}
// Styles required by GOVUK.HighlightActiveNavItem JS
&__list .active {
font-weight: bold;
}
}
4 changes: 2 additions & 2 deletions app/views/content_items/service_manual_guide.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@
</div>
</div>

<div class="grid-row">
<div class="grid-row" data-module="highlight-active-section-heading">
<div class="column-third">

<!-- Page contents -->
<div class="page-contents">
<div class="page-contents js-page-contents js-stick-at-top-when-scrolling js-sticky-resize">
<h2 class="page-contents__title">Page contents:</h2>
<ul class="page-contents__list">
<% @content_item.header_links.each do |header_link| %>
Expand Down
95 changes: 95 additions & 0 deletions spec/javascripts/highlight-active-section-heading-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/* eslint-env jasmine */
/* eslint-disable no-multi-str */

describe('A highlight active section heading module', function () {
'use strict'

var module
var $element

beforeEach(function () {
module = new GOVUK.Modules.HighlightActiveSectionHeading()

$element = $('<div class="grid-row" data-module="highlight-active-section-heading">\
<div class="column-third">\
<div class="page-contents js-page-contents js-stick-at-top-when-scrolling">\
<h2 class="page-contents__title">Page contents:</h2>\
<ul class="page-contents__list">\
<li><a href="#section-1">Section 1</a></li>\
<li><a href="#section-2">Section 2</a></li>\
<li><a href="#section-3">Section 3</a></li>\
</ul>\
</div>\
</div>\
<div class="column-two-thirds">\
<div class="govspeak-wrapper">\
<div class="govuk-govspeak">\
<h2 id="section-1">Section 1</h2>\
<p>Section 1 text</p>\
<h2 id="section-2">Section 2</h2>\
<p>Section 2 text</p>\
<h2 id="section-3">Section 3</h2>\
<p>Section 3 text</p>\
</div>\
</div>\
</div>\
</div>')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering how much of this fixture is useful. We're faking the positions of the window scroll and elements.

Maybe a more minimal fixture would:

a) make it easier to read what markup is required
b) would be just as valid a test

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've simplified the fixture to make it easier to read.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍


module.getWindowDimensions = function () {
return {
height: 768,
width: 1024
}
}
module.getFooterPosition = function () {
return {
top: 500
}
}
module.getHeadingPosition = function () {
return {
top: 100
}
}
module.getNextHeadingPosition = function () {
return {
top: 200
}
}
})

afterEach(function () {
$(document).off()
})

// The anchor link with the href matching testHref should be highlighted
function isLinkHighlighted (testHref) {
var $anchor = $element.find('.js-page-contents a[href="' + testHref + '"]')
expect($anchor.hasClass('active')).toBe(true)
}

it('When the page loads, it has no highlighted nav items', function () {
module.getWindowPositions = function () {
return {
scrollTop: 0
}
}
module.start($element)

var $anchors = $element.find('.js-page-contents a')
expect($anchors.hasClass('active')).toBe(false)
})

it('When the page is scrolled, it highlights a nav item', function () {
module.getWindowPositions = function () {
return {
scrollTop: 180
}
}
module.start($element)

var $anchors = $element.find('.js-page-contents a')

isLinkHighlighted('#section-3')
})
})