diff --git a/app/assets/javascripts/modules/main-navigation.js b/app/assets/javascripts/modules/main-navigation.js index 79a2b39db7..1959aa624c 100644 --- a/app/assets/javascripts/modules/main-navigation.js +++ b/app/assets/javascripts/modules/main-navigation.js @@ -1,9 +1,11 @@ +/* istanbul ignore next */ window.GOVUK = window.GOVUK || {} window.GOVUK.Modules = window.GOVUK.Modules || {}; (function (Modules) { 'use strict' + /* istanbul ignore next */ function MainNavigation (module) { this.module = module this.module.button = this.module.querySelector('button') diff --git a/app/assets/javascripts/modules/sticky-element-container.js b/app/assets/javascripts/modules/sticky-element-container.js new file mode 100644 index 0000000000..dfc45f8bfd --- /dev/null +++ b/app/assets/javascripts/modules/sticky-element-container.js @@ -0,0 +1,118 @@ +/* + This module will cause a child in the target element to: + - hide when the top of the target element is visible; + - stick to the bottom of the window while the parent element is in view; + - stick to the bottom of the target when the user scrolls past the bottom. + + Use 'data-module="sticky-element-container"' to instantiate, and add + `[data-sticky-element]` to the child you want to position. +*/ + +window.GOVUK = window.GOVUK || {} +window.GOVUK.Modules = window.GOVUK.Modules || {}; + +/* istanbul ignore next */ +(function (Modules) { + function StickyElementContainer (element) { + this.wrapper = element + this.stickyElement = this.wrapper.querySelector('[data-sticky-element]') + this.hasResized = true + this.hasScrolled = true + this.interval = 50 + this.windowVerticalPosition = 1 + this.startPosition = 0 + this.stopPosition = 0 + } + + StickyElementContainer.prototype.init = function () { + if (!this.stickyElement) return + + window.onresize = this.onResize.bind(this) + window.onscroll = this.onScroll.bind(this) + setInterval(this.checkResize.bind(this), this.interval) + setInterval(this.checkScroll.bind(this), this.interval) + this.checkResize() + this.checkScroll() + this.stickyElement.classList.add('sticky-element--enabled') + } + + StickyElementContainer.prototype.getWindowDimensions = function () { + return { + height: window.innerHeight, + width: window.innerWidth + } + } + + StickyElementContainer.prototype.getWindowPositions = function () { + return { + scrollTop: window.scrollY + } + } + + StickyElementContainer.prototype.onResize = function () { + this.hasResized = true + } + + StickyElementContainer.prototype.onScroll = function () { + this.hasScrolled = true + } + + StickyElementContainer.prototype.checkResize = function () { + if (this.hasResized) { + this.hasResized = false + this.hasScrolled = true + + var windowDimensions = this.getWindowDimensions() + var elementHeight = this.wrapper.offsetHeight || parseFloat(this.wrapper.style.height.replace('px', '')) + this.startPosition = this.wrapper.offsetTop + this.stopPosition = this.wrapper.offsetTop + elementHeight - windowDimensions.height + } + } + + StickyElementContainer.prototype.checkScroll = function () { + if (this.hasScrolled) { + this.hasScrolled = false + + this.windowVerticalPosition = this.getWindowPositions().scrollTop + + this.updateVisibility() + this.updatePosition() + } + } + + StickyElementContainer.prototype.updateVisibility = function () { + var isPastStart = this.startPosition < this.windowVerticalPosition + if (isPastStart) { + this.show() + } else { + this.hide() + } + } + + StickyElementContainer.prototype.updatePosition = function () { + var isPastEnd = this.stopPosition < this.windowVerticalPosition + if (isPastEnd) { + this.stickToParent() + } else { + this.stickToWindow() + } + } + + StickyElementContainer.prototype.stickToWindow = function () { + this.stickyElement.classList.add('sticky-element--stuck-to-window') + } + + StickyElementContainer.prototype.stickToParent = function () { + this.stickyElement.classList.remove('sticky-element--stuck-to-window') + } + + StickyElementContainer.prototype.show = function () { + this.stickyElement.classList.remove('sticky-element--hidden') + } + + StickyElementContainer.prototype.hide = function () { + this.stickyElement.classList.add('sticky-element--hidden') + } + + Modules.StickyElementContainer = StickyElementContainer +})(window.GOVUK.Modules) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 59bca1ca84..69d082d62a 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -7,6 +7,7 @@ $govuk-include-default-font-face: false; // Helper stylesheets (things on more than one page layout) @import "helpers/content-bottom-margin"; +@import "helpers/sticky-element-container"; // frontend mixins @import "mixins/margins"; diff --git a/app/assets/stylesheets/components/_contents-list-with-body.scss b/app/assets/stylesheets/components/_contents-list-with-body.scss new file mode 100644 index 0000000000..3fe1ac5e06 --- /dev/null +++ b/app/assets/stylesheets/components/_contents-list-with-body.scss @@ -0,0 +1,49 @@ +@import "govuk_publishing_components/individual_component_support"; + +.app-c-contents-list-with-body__link-container { + margin: 0 auto; + padding: 0; + + @include govuk-media-query($from: tablet) { + max-width: 1024px; + } + + .app-c-back-to-top { + margin-left: 0; + margin-right: 0; + } +} + +.app-c-contents-list-with-body__link-wrapper { + padding-bottom: govuk-spacing(2); + + .app-c-back-to-top { + padding-bottom: govuk-spacing(2); + } +} + +.app-c-contents-list-with-body__link-wrapper.sticky-element--stuck-to-window { + background-color: govuk-colour("light-grey"); + bottom: -1px; // 'Fix' for anomalous 1px margin which sporadically appears below this element. + left: 0; + margin: 0; + padding: 0; + padding-left: 0; + position: fixed; + width: 100%; + z-index: 10; + + @include govuk-media-query($from: tablet) { + padding-left: govuk-spacing(2); + } + + .app-c-back-to-top { + margin-bottom: 0; + padding: govuk-spacing(3); + width: 66%; + + @include govuk-media-query($from: tablet) { + padding: govuk-spacing(4); + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/helpers/_sticky-element-container.scss b/app/assets/stylesheets/helpers/_sticky-element-container.scss new file mode 100644 index 0000000000..a8826e85f0 --- /dev/null +++ b/app/assets/stylesheets/helpers/_sticky-element-container.scss @@ -0,0 +1,23 @@ +.govuk-frontend-supported .sticky-element { + position: absolute; + bottom: 0; + + &--stuck-to-window { + bottom: 0; + position: fixed; + } + + &--enabled { + transition: opacity, .3s, ease; + opacity: 1; + + @include govuk-media-query($until: tablet) { + position: static; + } + } + + &--hidden { + opacity: 0; + pointer-events: none; + } +} \ No newline at end of file diff --git a/app/views/components/_contents_list_with_body.html.erb b/app/views/components/_contents_list_with_body.html.erb new file mode 100644 index 0000000000..e9bc66b33c --- /dev/null +++ b/app/views/components/_contents_list_with_body.html.erb @@ -0,0 +1,29 @@ +<% add_app_component_stylesheet("contents-list-with-body") %> +<% block = yield %> +<% unless block.empty? %> + <% + contents ||= [] + sticky_attr = "sticky-element-container" if contents.any? + %> + <%= tag.div( + id: "contents", + class: "app-c-contents-list-with-body", + data: { + module: sticky_attr, + }, + ) do %> + <% if contents.any? %> +
+ <%= render "govuk_publishing_components/components/contents_list", contents: contents %> +
+ <% end %> + <%= block %> + <% if contents.any? %> + + <% end %> + <% end %> +<% end %> diff --git a/app/views/components/docs/contents_list_with_body.yml b/app/views/components/docs/contents_list_with_body.yml new file mode 100644 index 0000000000..1d927f4bd4 --- /dev/null +++ b/app/views/components/docs/contents_list_with_body.yml @@ -0,0 +1,89 @@ +name: Contents list with body +description: Combines contents-list and back-to-top components with block of body markup +body: | + Wraps the HTML/ERB block passed with a content list and back to top link. + + Expects one argument: + + * `contents` - The contents to build a contents list, if this item is empty the contents-list and back-to-top links are excluded but the block passed is still rendered. + +accessibility_criteria: | + - The component must have a text contrast ratio higher than 4.5:1 against the background colour to meet WCAG AA. + - The component embeds contents-list and back-to-top components. Please see the relevant accessibility criteria: + + * [back-to-top](/component-guide/back_to_top) + * [contents-list](/component-guide/contents_list) +shared_accessibility_criteria: + - link +examples: + default: + data: + contents: + - href: "#reference-unless-undertakings-accepted" + text: Reference unless undertakings accepted + - href: "#notice-of-extension-of-the-preliminary-assessment-period" + text: Notice of extension of the preliminary assessment period + - href: "#invitation-to-comment-closes-13-november-2017" + text: "Invitation to comment: closes 13 November 2017" + - href: "#launch-of-merger-inquiry" + text: Launch of merger inquiry + block: | +
+

Statutory timetable

+ + + + + + + + + + + + + + + + + + + + + +
Phase 1 dateAction
3 January 2018Decision announced
30 October to 13 November 2017Invitation to comment
27 October 2017Launch of merger inquiry
+ +

Phase 1

+ +

Reference unless undertakings accepted

+ +

3 January 2018: The CMA has decided, on the information currently available to it, that it is or may be the case that this merger may be expected to result in a substantial lessening of competition within a market or markets in the United Kingdom. This merger will be referred for a phase 2 investigation unless the parties offer acceptable undertakings to address these competition concerns. The full text of the decision will be available shortly.

+ +

Notice of extension of the preliminary assessment period

+ +

Invitation to comment: closes 13 November 2017

+

30 October 2017: The Competition and Markets Authority (CMA) is considering whether it is or may be the case that this transaction, if carried into effect, will result in the creation of a relevant merger situation under the merger provisions of the Enterprise Act 2002 and, if so, whether the creation of that situation may be expected to result in a substantial lessening of competition within any market or markets in the United Kingdom for goods or services.

+

Launch of merger inquiry

+

27 October 2017: The CMA announced the launch of its merger inquiry by notice to the parties.

+ +

Contact

+

Please send written representations about any competition issues to:

+

+
Competition and Markets Authority +
Victoria House +
Southampton Row +
London +
WC1B 4AD +
+

+
\ No newline at end of file diff --git a/config/initializers/dartsass.rb b/config/initializers/dartsass.rb index 41e6bc526a..09f9c456cc 100644 --- a/config/initializers/dartsass.rb +++ b/config/initializers/dartsass.rb @@ -3,6 +3,7 @@ "static-error-pages.scss" => "static-error-pages.css", "components/_back-to-top.scss" => "components/_back-to-top.css", "components/_calendar.scss" => "components/_calendar.css", + "components/_contents-list-with-body.scss" => "components/_contents-list-with-body.css", "components/_download-link.scss" => "components/_download-link.css", "components/_figure.scss" => "components/_figure.css", "components/_published-dates.scss" => "components/_published-dates.css", diff --git a/spec/components/contents_list_with_body_spec.rb b/spec/components/contents_list_with_body_spec.rb new file mode 100644 index 0000000000..4f39f1baa6 --- /dev/null +++ b/spec/components/contents_list_with_body_spec.rb @@ -0,0 +1,53 @@ +RSpec.describe "ContentsListWithBodyComponent", type: :view do + def component_name + "contents_list_with_body" + end + + let(:block) { "

Foo

".html_safe } + + let(:contents_list) do + [ + { href: "/one", text: "1. One" }, + { href: "/two", text: "2. Two" }, + ] + end + + it "renders nothing without a block" do + render_component(contents: contents_list) + expect(rendered).to be_empty + end + + it "yields the block without contents data" do + render_component({}) { block } + expect(rendered).to include(block) + end + + it "renders a sticky-element-container" do + render_component(contents: contents_list) { block } + + expect(rendered).to have_css("#contents.app-c-contents-list-with-body") + expect(rendered).to have_css("#contents[data-module='sticky-element-container']") + end + + it "does not apply the sticky-element-container data-module without contents data" do + render_component({}) { block } + + expect(rendered).to have_css("#contents[data-module='sticky-element-container']", count: 0) + end + + it "renders a contents-list component" do + render_component(contents: contents_list) { block } + + expect(rendered).to have_css(".app-c-contents-list-with-body .gem-c-contents-list") + expect(rendered).to have_css(".gem-c-contents-list__link[href='/one']", text: "1. One") + end + + it "renders a back-to-top component" do + render_component(contents: contents_list) { block } + + expect(rendered).to have_css(%(.app-c-contents-list-with-body + .app-c-contents-list-with-body__link-wrapper + .app-c-contents-list-with-body__link-container + .app-c-back-to-top[href='#contents'])) + end +end diff --git a/spec/javascripts/unit/modules/sticky-element-container.spec.js b/spec/javascripts/unit/modules/sticky-element-container.spec.js new file mode 100644 index 0000000000..8c22ec2863 --- /dev/null +++ b/spec/javascripts/unit/modules/sticky-element-container.spec.js @@ -0,0 +1,73 @@ +describe('A sticky-element-container module', function () { + 'use strict' + + var GOVUK = window.GOVUK + + describe('on desktop', function () { + var $element + var $footer + var instance + + beforeEach(function () { + $element = $( + '
' + + '
' + + 'Content' + + '
' + + '
' + ) + + instance = new GOVUK.Modules.StickyElementContainer($element[0]) + $footer = $element.find('[data-sticky-element]') + + instance.getWindowDimensions = function () { + return { + height: 768, + width: 1024 + } + } + }) + + it('hides the element, when scrolled at the top', function () { + instance.getWindowPositions = function () { + return { + scrollTop: 0 + } + } + + instance.checkResize() + instance.checkScroll() + + expect($footer.hasClass('sticky-element--hidden')).toBe(true) + expect($footer.hasClass('sticky-element--stuck-to-window')).toBe(true) + }) + + it('shows the element, stuck to the window, when scrolled in the middle', function () { + instance.getWindowPositions = function () { + return { + scrollTop: 5000 + } + } + + instance.checkResize() + instance.checkScroll() + + expect($footer.hasClass('sticky-element--hidden')).toBe(false) + expect($footer.hasClass('sticky-element--stuck-to-window')).toBe(true) + }) + + it('shows the element, stuck to the parent, when scrolled at the bottom', function () { + instance.getWindowPositions = function () { + return { + scrollTop: 9800 + } + } + + instance.checkResize() + instance.checkScroll() + + expect($footer.hasClass('sticky-element--hidden')).toBe(false) + expect($footer.hasClass('sticky-element--stuck-to-window')).toBe(false) + }) + }) +})