diff --git a/__tests__/back-to-top.test.js b/__tests__/back-to-top.test.js new file mode 100644 index 0000000000..522ead43c6 --- /dev/null +++ b/__tests__/back-to-top.test.js @@ -0,0 +1,54 @@ +/* eslint-env jest */ +const configPaths = require('../config/paths.json') +const PORT = configPaths.testPort + +let browser +let page +let baseUrl = 'http://localhost:' + PORT + +beforeAll(async (done) => { + browser = global.browser + page = await browser.newPage() + await page.evaluateOnNewDocument(() => { + window.__TESTS_RUNNING = true + }) + done() +}) + +afterAll(async (done) => { + await page.close() + done() +}) + +const BACK_TO_TOP_LINK_SELECTOR = '[data-module="app-back-to-top"] a' + +describe('Back to top', () => { + it('is always visible when JavaScript is disabled', async () => { + await page.setJavaScriptEnabled(false) + await page.goto(`${baseUrl}/styles/colour/`, { waitUntil: 'load' }) + const isBackToTopVisible = await page.waitForSelector(BACK_TO_TOP_LINK_SELECTOR, { visible: true }) + expect(isBackToTopVisible).toBeTruthy() + }) + it('is hidden when at the top of the page', async () => { + await page.goto(`${baseUrl}/styles/colour/`, { waitUntil: 'load' }) + const isBackToTopHidden = await page.waitForSelector(BACK_TO_TOP_LINK_SELECTOR, { visible: false }) + expect(isBackToTopHidden).toBeTruthy() + }) + it('is visible when at the bottom of the page', async () => { + await page.goto(`${baseUrl}/styles/colour/`, { waitUntil: 'load' }) + // Scroll to the bottom of the page + await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight)) + const isBackToTopVisible = await page.waitForSelector(BACK_TO_TOP_LINK_SELECTOR, { visible: true }) + expect(isBackToTopVisible).toBeTruthy() + }) + it('goes back to the top of the page when interacted with', async () => { + await page.goto(`${baseUrl}/styles/colour/`, { waitUntil: 'load' }) + // Scroll to the bottom of the page + await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight)) + // Make sure the back to top component is available to click + await page.waitForSelector(BACK_TO_TOP_LINK_SELECTOR, { visible: true }) + await page.click(BACK_TO_TOP_LINK_SELECTOR) + const isAtTopOfPage = await page.evaluate(() => window.scrollY === 0) + expect(isAtTopOfPage).toBeTruthy() + }) +}) diff --git a/src/javascripts/application.js b/src/javascripts/application.js index a57b21938a..7e9f4dc509 100644 --- a/src/javascripts/application.js +++ b/src/javascripts/application.js @@ -1,3 +1,4 @@ +import BackToTop from './components/back-to-top.js' import common from 'govuk-frontend/common' import CookieBanner from './components/cookie-banner.js' import Example from './components/example.js' @@ -39,3 +40,8 @@ new MobileNav().init() // Initialise search var $searchContainer = document.querySelector('[data-module="app-search"]') new Search($searchContainer).init() + +// Initialise back to top +var $backToTop = document.querySelector('[data-module="app-back-to-top"]') +var $observedElement = document.querySelector('.app-subnav') +new BackToTop($backToTop, { $observedElement: $observedElement }).init() diff --git a/src/javascripts/components/back-to-top.js b/src/javascripts/components/back-to-top.js new file mode 100644 index 0000000000..1097516b5b --- /dev/null +++ b/src/javascripts/components/back-to-top.js @@ -0,0 +1,62 @@ +import 'govuk-frontend/vendor/polyfills/Function/prototype/bind' + +function BackToTop ($module, options) { + this.$module = $module + this.$observedElement = options.$observedElement + this.intersectionRatio = 0 +} + +BackToTop.prototype.init = function () { + var $observedElement = this.$observedElement + + // If there's no element for the back to top to follow, exit early. + if (!$observedElement) { + return + } + + if (!('IntersectionObserver' in window)) { + // If there's no support fallback to regular sticky behaviour + return this.update() + } + + // Create new IntersectionObserver + var observer = new window.IntersectionObserver(function (entries) { + // Available data when an intersection happens + // Back to top visibility + // Element enters the viewport + if (entries[0].intersectionRatio !== 0) { + // How much of the element is visible + this.intersectionRatio = entries[0].intersectionRatio + // Element leaves the viewport + } else { + this.intersectionRatio = 0 + } + this.update() + }.bind(this), { + // Call the observer, when the element enters the viewport, + // when 25%, 50%, 75% and the whole element are visible + threshold: [0, 0.25, 0.5, 0.75, 1] + }) + + observer.observe($observedElement) +} + +BackToTop.prototype.update = function () { + var thresholdPercent = (this.intersectionRatio * 100) + + if (thresholdPercent === 100) { + this.hide() + } else if (thresholdPercent < 90) { + this.show() + } +} + +BackToTop.prototype.hide = function () { + this.$module.classList.add('app-back-to-top--hidden') +} + +BackToTop.prototype.show = function () { + this.$module.classList.remove('app-back-to-top--hidden') +} + +export default BackToTop diff --git a/src/stylesheets/components/_back-to-top.scss b/src/stylesheets/components/_back-to-top.scss new file mode 100644 index 0000000000..464a92c0fb --- /dev/null +++ b/src/stylesheets/components/_back-to-top.scss @@ -0,0 +1,21 @@ +.app-back-to-top { + position: -webkit-sticky; // Needed for Safari on OSX + position: sticky; // sass-lint:disable-line no-duplicate-properties + top: govuk-spacing(6); + margin-bottom: govuk-spacing(6); +} + +.app-back-to-top__icon { + display: inline-block; + width: .8em; + height: 1em; + margin-top: -(govuk-spacing(1)); + margin-right: govuk-spacing(2); + vertical-align: middle; +} + +@supports (position: sticky) { + .js-enabled .app-back-to-top--hidden { + display: none; + } +} diff --git a/src/stylesheets/components/_banner.scss b/src/stylesheets/components/_banner.scss new file mode 100644 index 0000000000..b6b847edc8 --- /dev/null +++ b/src/stylesheets/components/_banner.scss @@ -0,0 +1,12 @@ +.app-phase-banner { + @include govuk-media-query($until: tablet) { + margin-right: 0; + margin-left: 0; + padding-right: govuk-spacing(3); + padding-left: govuk-spacing(3); + } + + @include govuk-media-query($from: tablet) { + border-bottom: 0; + } +} diff --git a/src/stylesheets/components/_cookie-banner.scss b/src/stylesheets/components/_cookie-banner.scss index b1c2c2cbc1..6565ad650f 100644 --- a/src/stylesheets/components/_cookie-banner.scss +++ b/src/stylesheets/components/_cookie-banner.scss @@ -6,9 +6,7 @@ width: 100%; padding-top: govuk-spacing(3); - padding-right: govuk-spacing(3); padding-bottom: govuk-spacing(3); - padding-left: govuk-spacing(3); background-color: lighten(desaturate(govuk-colour("light-blue"), 8.46), 42.55); } @@ -16,10 +14,6 @@ display: none; } -.app-cookie-banner__message { - margin: 0; -} - @include govuk-media-query($media-type: print) { .app-cookie-banner { display: none !important; diff --git a/src/stylesheets/components/_footer.scss b/src/stylesheets/components/_footer.scss index 4af487b203..33184078fc 100644 --- a/src/stylesheets/components/_footer.scss +++ b/src/stylesheets/components/_footer.scss @@ -4,13 +4,6 @@ // GOV.UK Frontend footer adapted for full width @include govuk-exports("app-footer") { - .app-footer { - @include govuk-media-query($from: tablet) { - display: flex; - flex-direction: column; - flex: 1 0 auto; - } - } .app-width-container--full { min-width: calc(100% - #{$govuk-gutter * 2}); diff --git a/src/stylesheets/components/_header.scss b/src/stylesheets/components/_header.scss index e8ce2ee3a3..82939cdac5 100644 --- a/src/stylesheets/components/_header.scss +++ b/src/stylesheets/components/_header.scss @@ -11,11 +11,16 @@ @include govuk-exports("app-header") { .app-header { - padding: govuk-spacing(2) govuk-spacing(3); + box-sizing: border-box; + width: 100%; border-bottom: 10px solid govuk-colour("blue"); color: govuk-colour("white"); background: govuk-colour("black"); @include govuk-clearfix; + + @include govuk-media-query($from: desktop) { + padding: govuk-spacing(2) 0; + } } .app-header__logotype { diff --git a/src/stylesheets/components/_navigation.scss b/src/stylesheets/components/_navigation.scss index f69d7b2e2d..92d1102651 100644 --- a/src/stylesheets/components/_navigation.scss +++ b/src/stylesheets/components/_navigation.scss @@ -1,16 +1,18 @@ .app-navigation { $navigation-height: 53px; - padding-right: govuk-spacing(3); - padding-left: govuk-spacing(3); - background-color: $app-light-grey; + box-sizing: border-box; @include govuk-font(19, $weight: bold); + width: 100%; @include govuk-media-query($until: tablet) { display: none; } + @include govuk-media-query($from: tablet) { + margin-left: -(govuk-spacing(3)); + } + &__list { - margin: 0; padding: 0; list-style: none; diff --git a/src/stylesheets/components/_pane.scss b/src/stylesheets/components/_pane.scss index 7fdcc8a42a..b2cfc7dc6b 100644 --- a/src/stylesheets/components/_pane.scss +++ b/src/stylesheets/components/_pane.scss @@ -1,23 +1,12 @@ @include govuk-exports("app-pane") { - $toc-width: 300px; + $toc-width: 260px; $toc-width-tablet: 210px; .app-pane.app-pane--enabled { - $pane-height: 100vh; - overflow: hidden; - @include govuk-media-query($from: tablet) { display: flex; flex-direction: column; } - - @include govuk-media-query($from: tablet, $and: "(orientation: portrait)") { - height: $pane-height; - } - - @include govuk-media-query($from: desktop) { - height: $pane-height; - } } .app-pane__header { @@ -35,6 +24,7 @@ .app-pane__nav { @include govuk-media-query($from: tablet) { display: flex; + background-color: $app-light-grey; flex-direction: column; flex: 1 0 auto; } @@ -45,22 +35,17 @@ display: flex; position: relative; min-height: 0; - overflow: hidden; - flex: 1 1 100%; + overflow: inherit; + } - > * { - overflow-x: scroll; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: -ms-autohiding-scrollbar; - } + @include govuk-media-query(1160px) { + width: 100%; } } .app-pane__subnav { - border-right: 1px solid $govuk-border-colour; @include govuk-media-query($from: tablet) { width: $toc-width-tablet; - border-right: 1px solid $govuk-border-colour; flex: 0 0 auto; } @include govuk-media-query($from: desktop) { @@ -72,15 +57,8 @@ @include govuk-media-query($from: tablet) { display: flex; min-width: 0; - margin-left: auto; - flex: 1 1 auto; + flex: 1 1 100%; flex-direction: column; - - // Stick footer to bottom of screen if content is shorter than viewport - main { - display: block; - flex: 1 0 auto; - } } } @@ -105,7 +83,6 @@ .app-pane__content { margin-left: -1px; overflow-x: hidden; - border-left: 1px solid $govuk-border-colour; } } } diff --git a/src/stylesheets/components/_subnav.scss b/src/stylesheets/components/_subnav.scss index 809166c135..dad1f9947a 100644 --- a/src/stylesheets/components/_subnav.scss +++ b/src/stylesheets/components/_subnav.scss @@ -1,8 +1,12 @@ @include govuk-exports("app-subnav") { .app-subnav { - padding: govuk-spacing(3); + padding: govuk-spacing(6) govuk-spacing(3) 0 0; @include govuk-font(16); + + @include govuk-media-query($from: tablet) { + margin-left: -(govuk-spacing(3)); + } } .app-subnav__section { diff --git a/src/stylesheets/main.scss b/src/stylesheets/main.scss index 0217a9546b..c1bc033a85 100644 --- a/src/stylesheets/main.scss +++ b/src/stylesheets/main.scss @@ -1,3 +1,5 @@ +$govuk-page-width: 1100px !default; + @import "govuk-frontend/all"; // App-specific variables @@ -5,6 +7,8 @@ $app-light-grey: #f8f8f8; $app-code-color: #dd1144; // App-specific components +@import "components/back-to-top"; +@import "components/banner"; @import "components/contact-panel"; @import "components/cookie-banner"; @import "components/example"; @@ -46,21 +50,6 @@ body { } } -// Mirrors Bootstrap 4 - open for discussion -$app-breakpoint-widescreen: 1200px; - -// This will be coming to FE later but making it app specific for now -.app-site-width-container { - @media (min-width: $app-breakpoint-widescreen) { - max-width: 1100px; - } -} - -// This layout is currently used on error pages like 404 -.app-site-width-container--constraint { - max-width: 960px; -} - // This is consistent with Elements - will be changed in FE [class*="govuk-grid-column"] { @include govuk-media-query($until: desktop) { @@ -79,7 +68,12 @@ $app-breakpoint-widescreen: 1200px; .app-content { - @include govuk-responsive-padding(6); + padding: govuk-spacing(3) govuk-spacing(0); + + @include govuk-media-query($from: tablet) { + padding: govuk-spacing(6); + padding-right: 0; + } h1 { max-width: 15em; diff --git a/views/layouts/layout-pane.njk b/views/layouts/layout-pane.njk index f38d173283..53b60cdffb 100644 --- a/views/layouts/layout-pane.njk +++ b/views/layouts/layout-pane.njk @@ -7,9 +7,10 @@ {% block appPaneClasses %}app-pane--enabled{% endblock %} {% block body %} -
+
{% include "_subnav.njk" %} + {% include "_back-to-top.njk" %}
@@ -67,7 +68,7 @@

Discuss ‘{{title}}’ on GitHub

{% endif %}
- {% include "_footer.njk" %}
+{% include "_footer.njk" %} {% endblock %} diff --git a/views/layouts/layout.njk b/views/layouts/layout.njk index d080ddb061..c72bf7000f 100644 --- a/views/layouts/layout.njk +++ b/views/layouts/layout.njk @@ -1,6 +1,7 @@ {% extends "_generic.njk" %} {% block body %} +
{{ contents | safe }} @@ -10,4 +11,3 @@
{% endblock %} - diff --git a/views/partials/_back-to-top.njk b/views/partials/_back-to-top.njk new file mode 100644 index 0000000000..c70d833eb1 --- /dev/null +++ b/views/partials/_back-to-top.njk @@ -0,0 +1,9 @@ +{# Safari on OSX with `position: -webkit-sticky` requires a block level element. +To avoid a large focus area we use a wrapper element. #} +
+ + + + Back to top + +
\ No newline at end of file diff --git a/views/partials/_banner.njk b/views/partials/_banner.njk index 9ed92ad438..f85a7fcb3d 100644 --- a/views/partials/_banner.njk +++ b/views/partials/_banner.njk @@ -1,5 +1,5 @@ {% from "phase-banner/macro.njk" import govukPhaseBanner %} - +
{% if PULL_REQUEST %} {% set phaseBannerText %} This is a preview of @@ -16,15 +16,16 @@ "text": "preview", "classes": "app-tag--review" }, - "classes": "govuk-!-padding-left-3 govuk-!-padding-right-3", + "classes": "app-phase-banner govuk-width-container", "html": phaseBannerText }) }} {% else %} - {{ govukPhaseBanner({ - "tag": { - "text": "beta" - }, - "classes": "govuk-!-padding-left-3 govuk-!-padding-right-3", - "html": "This is a new service – your feedback will help us to improve it." - }) }} -{% endif %} + {{ govukPhaseBanner({ + "tag": { + "text": "beta" + }, + "classes": "app-phase-banner govuk-width-container", + "html": "This is a new service – your feedback will help us to improve it." + }) }} + {% endif %} +
diff --git a/views/partials/_cookie-banner.njk b/views/partials/_cookie-banner.njk index 2954bbd46a..8548da3175 100644 --- a/views/partials/_cookie-banner.njk +++ b/views/partials/_cookie-banner.njk @@ -1,4 +1,4 @@ diff --git a/views/partials/_footer.njk b/views/partials/_footer.njk index f1d149b358..f5e9ad3362 100644 --- a/views/partials/_footer.njk +++ b/views/partials/_footer.njk @@ -1,8 +1,8 @@ {% from "footer/macro.njk" import govukFooter %} {{ govukFooter({ - "classes": "app-footer app-footer--full ", - "containerClasses" : "app-width-container--full", + "classes": "app-footer app-footer--full", + "containerClasses" : "app-site-width-container", "navigation": [ { "title": "Related resources", diff --git a/views/partials/_header.njk b/views/partials/_header.njk index 33fcda31df..66f6a50bf3 100644 --- a/views/partials/_header.njk +++ b/views/partials/_header.njk @@ -1,36 +1,38 @@