From 368f4a0ec5c70f93b4c03db25bf4e85a3a43a2e6 Mon Sep 17 00:00:00 2001 From: owenatgov Date: Wed, 17 Feb 2021 12:26:09 +0000 Subject: [PATCH] Add option to add ids to headings on accordion Update CHANGELOG.md --- CHANGELOG.md | 1 + .../components/accordion.js | 60 ++++- .../components/_accordion.html.erb | 6 +- .../components/docs/accordion.yml | 29 ++ spec/components/accordion_spec.rb | 247 +++++++++++------- 5 files changed, 233 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5146d9467..2e53c29ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Fix cookie banner preview in the component guide ([PR #1935](https://github.com/alphagov/govuk_publishing_components/pull/1935)) * Implements scroll tracking for the covid pages ([PR #1942](https://github.com/alphagov/govuk_publishing_components/pull/1942)) +* Add anchor navigation feature to accordions ([PR #1937](https://github.com/alphagov/govuk_publishing_components/pull/1937)) ## 24.2.0 diff --git a/app/assets/javascripts/govuk_publishing_components/components/accordion.js b/app/assets/javascripts/govuk_publishing_components/components/accordion.js index 6611670da9..9aea61db46 100644 --- a/app/assets/javascripts/govuk_publishing_components/components/accordion.js +++ b/app/assets/javascripts/govuk_publishing_components/components/accordion.js @@ -11,8 +11,9 @@ window.GOVUK.Modules = window.GOVUK.Modules || {}; GemAccordion.prototype.start = function ($module) { this.$module = $module[0] + this.sectionClass = 'gem-c-accordion__section' this.moduleId = this.$module.getAttribute('id') - this.sections = this.$module.querySelectorAll('.gem-c-accordion__section') + this.sections = this.$module.querySelectorAll('.' + this.sectionClass) this.openAllButton = '' this.browserSupportsSessionStorage = helper.checkForSessionStorage() this.controlsClass = 'gem-c-accordion__controls' @@ -36,6 +37,12 @@ window.GOVUK.Modules = window.GOVUK.Modules || {}; this.initControls() this.initSectionHeaders() + // Feature flag for anchor tag navigation used on manuals + if (this.$module.getAttribute('data-anchor-navigation')) { + this.openByAnchorOnLoad() + this.addEventListenersForAnchors() + } + // See if "Show all sections" button text should be updated var areAllSectionsOpen = this.checkIfAllSectionsOpen() this.updateOpenAllButton(areAllSectionsOpen) @@ -59,7 +66,7 @@ window.GOVUK.Modules = window.GOVUK.Modules || {}; accordionControls.appendChild(this.openAllButton) this.$module.insertBefore(accordionControls, this.$module.firstChild) - // Build addtional wrapper for open all toggle text, place icon after wrapped text. + // Build additional wrapper for open all toggle text, place icon after wrapped text. var wrapperOpenAllText = document.createElement('span') wrapperOpenAllText.classList.add(this.openAllTextClass) this.openAllButton.insertBefore(wrapperOpenAllText, this.openAllButton.childNodes[0] || null) @@ -74,6 +81,7 @@ window.GOVUK.Modules = window.GOVUK.Modules || {}; nodeListForEach(this.sections, function (section, i) { // Set header attributes var header = section.querySelector('.' + this.sectionHeaderClass) + this.initHeaderAttributes(header, i) this.setExpanded(this.isExpanded(section), section) @@ -106,12 +114,12 @@ window.GOVUK.Modules = window.GOVUK.Modules || {}; srPause.classList.add('govuk-visually-hidden') srPause.innerHTML = ', ' - // Build addtional copy for assistive technology - var srAddtionalCopy = document.createElement('span') - srAddtionalCopy.classList.add('govuk-visually-hidden') - srAddtionalCopy.innerHTML = ' this section' + // Build additional copy for assistive technology + var srAdditionalCopy = document.createElement('span') + srAdditionalCopy.classList.add('govuk-visually-hidden') + srAdditionalCopy.innerHTML = ' this section' - // Build addtional wrapper for toggle text, place icon after wrapped text. + // Build additional wrapper for toggle text, place icon after wrapped text. var wrapperShowHideIcon = document.createElement('span') var icon = document.createElement('span') icon.classList.add(this.upChevonIconClass) @@ -138,7 +146,7 @@ window.GOVUK.Modules = window.GOVUK.Modules || {}; } button.appendChild(showIcons) - button.appendChild(srAddtionalCopy) + button.appendChild(srAdditionalCopy) } // When section toggled, set and store state @@ -283,5 +291,41 @@ window.GOVUK.Modules = window.GOVUK.Modules || {}; } } + // Navigate to and open accordions with anchored content on page load if a hash is present + GemAccordion.prototype.openByAnchorOnLoad = function () { + if (window.location.hash && this.$module.querySelector(window.location.hash)) { + this.openForAnchor(window.location.hash) + } + } + + // Add event listeners for links to open accordion sections when navigated to using said anchor links on the page + // Adding an event listener to all anchor link a tags in an accordion is risky but we circumvent this risk partially by only being a layer of accordion behaviour instead of any sort of change to link behaviour + GemAccordion.prototype.addEventListenersForAnchors = function () { + var links = this.$module.querySelectorAll('.' + this.sectionInnerContent + ' a[href*="#"]') + + links.forEach(function (link) { + if (link.pathname === window.location.pathname) { + link.addEventListener('click', this.openForAnchor.bind(this, link.hash)) + } + }.bind(this)) + } + + // Find the parent accordion section for the given id and open it + GemAccordion.prototype.openForAnchor = function (hash) { + var target = document.querySelector(hash) + var section = this.getContainingSection(target) + + this.setExpanded(true, section) + } + + // Loop through the given ids ancestors until the parent section class is found + GemAccordion.prototype.getContainingSection = function (target) { + while (!target.classList.contains(this.sectionClass)) { + target = target.parentElement + } + + return target + } + Modules.GemAccordion = GemAccordion })(window.GOVUK.Modules) diff --git a/app/views/govuk_publishing_components/components/_accordion.html.erb b/app/views/govuk_publishing_components/components/_accordion.html.erb index cec30d5243..487572fc5a 100644 --- a/app/views/govuk_publishing_components/components/_accordion.html.erb +++ b/app/views/govuk_publishing_components/components/_accordion.html.erb @@ -5,6 +5,7 @@ id ||= "default-id-#{SecureRandom.hex(4)}" items ||= [] condensed ||= false + anchor_navigation ||= false accordion_classes = %w(gem-c-accordion) accordion_classes << 'gem-c-accordion--condensed' if condensed @@ -12,6 +13,7 @@ data_attributes ||= {} data_attributes[:module] = 'gem-accordion' + data_attributes[:anchor_navigation] = anchor_navigation %> <% if items.any? %> <%= tag.div(class: accordion_classes, id: id, data: data_attributes) do %> @@ -19,8 +21,6 @@ <% index = i + 1 - item[:data_attributes] ||= nil - section_classes = %w(gem-c-accordion__section) section_classes << 'gem-c-accordion__section--expanded' if item[:expanded] @@ -29,7 +29,7 @@ <%= tag.section(class: section_classes) do %>
- <%= content_tag(shared_helper.get_heading_level, class: 'gem-c-accordion__section-heading') do %> + <%= content_tag(shared_helper.get_heading_level, class: 'gem-c-accordion__section-heading', id: item[:heading][:id]) do %> <%= tag.span(item[:heading][:text], id: "#{id}-heading-#{index}", data: item[:data_attributes], class: 'gem-c-accordion__section-button') %> <% end %> <%= tag.div(item[:summary][:text], id: "#{id}-summary-#{index}", class: summary_classes) if item[:summary].present? %> diff --git a/app/views/govuk_publishing_components/components/docs/accordion.yml b/app/views/govuk_publishing_components/components/docs/accordion.yml index f82ffd70ec..6bfc9f641b 100644 --- a/app/views/govuk_publishing_components/components/docs/accordion.yml +++ b/app/views/govuk_publishing_components/components/docs/accordion.yml @@ -274,6 +274,35 @@ examples: text: "How people read" content: html: "

This is the content for How people read.

" + with_the_anchor_link_navigation: + description: | + Some apps require custom ids per accordion section heading for linking between those specific sections, sometimes across multiple pages. An example of this is on manuals pages where multiple manuals will each include large sets of accordions and will reference between specific sections for ease of access to that content. [Live example](https://www.gov.uk/guidance/how-to-publish-on-gov-uk/creating-and-updating-pages#associations). + + This feature automatically opens accordions when an anchor link is clicked within another accordion that links to either the id of an accordion section heading or an id within the content of an accordion. This will also automatically navigate to and open accordions on page load using the same rules. + + This feature won't be used if the `anchor_navigation` flag isn't passed as true to mitigate performance risk from event listeners on a large number of links. + + Unlike with the accordion-wide custom id attribute, any ids passed to accordion headings as part of this feature aren't stored in `localStorage` so don't need to be unique across your domain, but **should still be unique in the context of the page**. + data: + anchor_navigation: true + items: + - heading: + text: "Writing well for the web" + id: "writing-well-for-the-web" + content: + html: "

This is the content for Writing well for the web.

" + - heading: + text: "Writing well for specialists" + content: + html: "

This is the content for Writing well for specialists.

" + - heading: + text: "Know your audience" + content: + html: "

This is the content for Know your audience.

" + - heading: + text: "How people read" + content: + html: "

This is the content for How people read.

" condensed_layout: description: | This is for when a smaller accordion is required. Since smaller screens trigger a single column layout, this modifier only makes the accordion smaller when viewed on large screens. diff --git a/spec/components/accordion_spec.rb b/spec/components/accordion_spec.rb index b8dd80793a..19e21217b4 100644 --- a/spec/components/accordion_spec.rb +++ b/spec/components/accordion_spec.rb @@ -13,18 +13,20 @@ def component_name it "places the title and content correctly" do test_data = { id: "test-for-heading-and-content", - items: [{ - heading: { text: "Heading 1" }, - content: { html: "

Content 1.

" }, - }, - { - heading: { text: "Heading 2" }, - content: { html: "

Content 2.

" }, - }, - { - heading: { text: "Heading 3" }, - content: { html: "

Content 3.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + content: { html: "

Content 1.

" }, + }, + { + heading: { text: "Heading 2" }, + content: { html: "

Content 2.

" }, + }, + { + heading: { text: "Heading 3" }, + content: { html: "

Content 3.

" }, + }, + ], } render_component(test_data) @@ -42,16 +44,18 @@ def component_name it "uses the correct id, and interpolates it correctly" do test_data = { id: "test-for-id", - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - }, - { - heading: { text: "Heading 2" }, - summary: { text: "Summary 2." }, - content: { html: "

Content 2.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + { + heading: { text: "Heading 2" }, + summary: { text: "Summary 2." }, + content: { html: "

Content 2.

" }, + }, + ], } render_component(test_data) @@ -64,11 +68,13 @@ def component_name it "an id is created when no id is set" do test_data = { - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + ], } render_component(test_data) @@ -86,11 +92,13 @@ def component_name test_data = { id: "heading-level-change", heading_level: 5, - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + ], } render_component(test_data) assert_select "h5", count: 1 @@ -98,11 +106,13 @@ def component_name it "sets a default margin bottom" do test_data = { - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + ], } render_component(test_data) @@ -112,11 +122,13 @@ def component_name it "sets a custom margin bottom" do test_data = { margin_bottom: 0, - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + ], } render_component(test_data) @@ -126,11 +138,13 @@ def component_name it "default heading level is used when heading_level is not set" do test_data = { id: "heading-level-default", - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + ], } render_component(test_data) @@ -140,16 +154,18 @@ def component_name it "data attribute is present when required" do test_data = { id: "test-for-data-attributes", - items: [{ - heading: { text: "Heading 1" }, - content: { html: "

Content 1.

" }, - data_attributes: { gtm: "google-tag-manager" }, - }, - { - heading: { text: "Heading 2" }, - content: { html: "

Content 2.

" }, - data_attributes: { gtm: "google-tag-manager" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + content: { html: "

Content 1.

" }, + data_attributes: { gtm: "google-tag-manager" }, + }, + { + heading: { text: "Heading 2" }, + content: { html: "

Content 2.

" }, + data_attributes: { gtm: "google-tag-manager" }, + }, + ], } render_component(test_data) @@ -161,14 +177,16 @@ def component_name it '`data-module="gem-accordion"` attribute is present when no custom data attributes given' do test_data = { id: "test-for-module-data-attributes", - items: [{ - heading: { text: "Heading 1" }, - content: { html: "

Content 1.

" }, - }, - { - heading: { text: "Heading 2" }, - content: { html: "

Content 2.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + content: { html: "

Content 1.

" }, + }, + { + heading: { text: "Heading 2" }, + content: { html: "

Content 2.

" }, + }, + ], } render_component(test_data) assert_select "[data-module='gem-accordion']", count: 1 @@ -180,20 +198,22 @@ def component_name data_attributes: { accordion: "first", }, - items: [{ - data_attributes: { - gtm: "this-is-gtm", + items: [ + { + data_attributes: { + gtm: "this-is-gtm", + }, + heading: { text: "Heading 1" }, + content: { html: "

Content 1.

" }, }, - heading: { text: "Heading 1" }, - content: { html: "

Content 1.

" }, - }, - { - data_attributes: { - gtm: "this-is-a-second-gtm", - }, - heading: { text: "Heading 2" }, - content: { html: "

Content 2.

" }, - }], + { + data_attributes: { + gtm: "this-is-a-second-gtm", + }, + heading: { text: "Heading 2" }, + content: { html: "

Content 2.

" }, + }, + ], } render_component(test_data) assert_select "[data-module='gem-accordion']", count: 1 @@ -207,32 +227,59 @@ def component_name test_data = { id: "condensed-layout", condensed: true, - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - expanded: true, - }, - { - heading: { text: "Heading 2" }, - summary: { text: "Summary 2." }, - content: { html: "

Content 2.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + expanded: true, + }, + { + heading: { text: "Heading 2" }, + summary: { text: "Summary 2." }, + content: { html: "

Content 2.

" }, + }, + ], } render_component(test_data) assert_select ".gem-c-accordion__section.gem-c-accordion__section--expanded", count: 1 assert_select ".gem-c-accordion__section", count: 2 end + it "adds id to heading when attribute passed" do + test_data = { + id: "condensed-layout", + condensed: true, + items: [ + { + heading: { text: "Heading 1", id: "heading-with-id" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + { + heading: { text: "Heading 2" }, + summary: { text: "Summary 2." }, + content: { html: "

Content 2.

" }, + }, + ], + } + render_component(test_data) + assert_select ".gem-c-accordion__section-heading", count: 2 + assert_select ".gem-c-accordion__section-heading#heading-with-id", count: 1 + assert_select ".gem-c-accordion__section-heading:not([id])", count: 1 + end + it "condensed class added correctly" do test_data = { id: "condensed-layout", condensed: true, - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + ], } render_component(test_data) assert_select ".gem-c-accordion.gem-c-accordion--condensed", count: 1 @@ -241,11 +288,13 @@ def component_name it "loop index starts at one, not zero (thanks Nunjucks.)" do test_data = { id: "thanks-nunjucks", - items: [{ - heading: { text: "Heading 1" }, - summary: { text: "Summary 1." }, - content: { html: "

Content 1.

" }, - }], + items: [ + { + heading: { text: "Heading 1" }, + summary: { text: "Summary 1." }, + content: { html: "

Content 1.

" }, + }, + ], } render_component(test_data)