diff --git a/docs/configuration.md b/docs/configuration.md index 2df215c6..59421101 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,6 +49,22 @@ Example: host: https://docs.cloud.service.gov.uk ``` +## `collapsible_nav` + +Enable collapsible navigation in the sidebar. Defaults to false; + +```yaml +collapsible_nav: true +``` + +## `multipage_nav` + +Enable multipage navigation in the sidebar. Defaults to false; + +```yaml +multipage_nav: true +``` + ## `max_toc_heading_level` Table of contents depth – how many levels to include in the table of contents. If your ToC is too long, reduce this number and we'll only show higher-level headings. diff --git a/docs/frontmatter.md b/docs/frontmatter.md index 0fa4b144..72846f2a 100644 --- a/docs/frontmatter.md +++ b/docs/frontmatter.md @@ -110,6 +110,17 @@ title: My beautiful page --- ``` +## `weight` + +Affects the order a page is displayed in the sidebar navigation tree. Lower +weights float to the top. Higher weights sink to the bottom. + +```yaml +--- +weight: 20 +--- +``` + ## `parent` The page that should be highlighted as ‘active’ in the navigation. diff --git a/example/config/tech-docs.yml b/example/config/tech-docs.yml index a827814d..62ac6b56 100644 --- a/example/config/tech-docs.yml +++ b/example/config/tech-docs.yml @@ -17,6 +17,12 @@ header_links: # Tracking ID from Google Analytics (e.g. UA-XXXX-Y) ga_tracking_id: +# Enable multipage navigation in the sidebar +multipage_nav: true + +# Enable collapsible navigation in the sidebar +collapsible_nav: true + # Table of contents depth – how many levels to include in the table of contents. # If your ToC is too long, reduce this number and we'll only show higher-level # headings. diff --git a/lib/assets/javascripts/_modules/collapsible-navigation.js b/lib/assets/javascripts/_modules/collapsible-navigation.js new file mode 100644 index 00000000..d15b3fde --- /dev/null +++ b/lib/assets/javascripts/_modules/collapsible-navigation.js @@ -0,0 +1,93 @@ +(function($, Modules) { + 'use strict'; + + Modules.CollapsibleNavigation = function () { + + var $contentPane; + var $nav; + var $topLevelItems; + var $headings; + var $listings; + + var $openLink; + var $closeLink; + + this.start = function ($element) { + $contentPane = $('.app-pane__content'); + $nav = $element; + $topLevelItems = $nav.find('> ul > li'); + $headings = $topLevelItems.find('> a'); + $listings = $topLevelItems.find('> ul'); + + // Attach collapsible heading functionality,on mobile and desktop + collapsibleHeadings(); + openActiveHeading(); + $contentPane.on('scroll', _.debounce(openActiveHeading, 100, { maxWait: 100 })); + + }; + + function collapsibleHeadings() { + for (var i = $topLevelItems.length - 1; i >= 0; i--) { + var $topLevelItem = $($topLevelItems[i]); + var $heading = $topLevelItem.find('> a'); + var $listing = $topLevelItem.find('> ul'); + // Only add collapsible functionality if there are children. + if ($listing.length == 0) { + continue; + } + $topLevelItem.addClass('collapsible'); + $listing.addClass('collapsible__body') + .attr('aria-expanded', 'false'); + $heading.addClass('collapsible__heading') + .after('') + $topLevelItem.on('click', '.collapsible__toggle', function(e) { + e.preventDefault(); + var $parent = $(this).parent(); + toggleHeading($parent); + }); + } + } + + function toggleHeading($topLevelItem) { + var isOpen = $topLevelItem.hasClass('is-open'); + var $heading = $topLevelItem.find('> a'); + var $body = $topLevelItem.find('collapsible__body'); + var $toggleLabel = $topLevelItem.find('.collapsible__toggle-label'); + + $topLevelItem.toggleClass('is-open', !isOpen); + $body.attr('aria-expanded', isOpen ? 'true' : 'false'); + $toggleLabel.text(isOpen ? 'Expand ' + $heading.text() : 'Collapse ' + $heading.text()); + } + + function openActiveHeading() { + var $activeElement; + var currentPath = window.location.pathname; + var isActiveTrail = '[href*="' + currentPath + '"]'; + // Add an exception for the root page, as every href includes / + if(currentPath == '/') { + isActiveTrail = '[href="' + currentPath + window.location.hash + '"]' + } + for (var i = $topLevelItems.length - 1; i >= 0; i--) { + var $element = $($topLevelItems[i]); + var $heading = $element.find('> a'); + // Check if this item href matches + if($heading.is(isActiveTrail)) { + $activeElement = $element; + break; + } + // Otherwise check the children + var $children = $element.find('li > a'); + var $matchingChildren = $children.filter(isActiveTrail); + if ($matchingChildren.length) { + $activeElement = $element; + break; + } + } + if($activeElement && !$activeElement.hasClass('is-open')) { + toggleHeading($activeElement); + } + } + + + }; +})(jQuery, window.GOVUK.Modules); diff --git a/lib/assets/javascripts/_modules/in-page-navigation.js b/lib/assets/javascripts/_modules/in-page-navigation.js index 2b2d80fa..eb498ad1 100644 --- a/lib/assets/javascripts/_modules/in-page-navigation.js +++ b/lib/assets/javascripts/_modules/in-page-navigation.js @@ -70,8 +70,12 @@ } function highlightActiveItemInToc(fragment) { - var $activeTocItem = $tocItems.filter('[href="' + fragment + '"]'); - + var $activeTocItem = $tocItems.filter('[href="' + window.location.pathname + fragment + '"]'); + // Navigation items with children don't contain fragments in their url + // Check to see if any nav items contain just the path name. + if(!$activeTocItem.get(0)) { + $activeTocItem = $tocItems.filter('[href="' + window.location.pathname + '"]'); + } if ($activeTocItem.get(0)) { $tocItems.removeClass('toc-link--in-view'); $activeTocItem.addClass('toc-link--in-view'); diff --git a/lib/assets/javascripts/_modules/table-of-contents.js b/lib/assets/javascripts/_modules/table-of-contents.js index 4bad8e91..c5d4da37 100644 --- a/lib/assets/javascripts/_modules/table-of-contents.js +++ b/lib/assets/javascripts/_modules/table-of-contents.js @@ -4,15 +4,23 @@ Modules.TableOfContents = function () { var $html = $('html'); + var $contentPane; var $toc; var $tocList; + var $topLevelItems; + var $headings; + var $listings; var $openLink; var $closeLink; this.start = function ($element) { + $contentPane = $('.app-pane__content'); $toc = $element; $tocList = $toc.find('.js-toc-list'); + $topLevelItems = $tocList.find('> ul > li'); + $headings = $topLevelItems.find('> a'); + $listings = $topLevelItems.find('> ul'); // Open link is not inside the module $openLink = $html.find('.js-toc-show'); @@ -44,7 +52,7 @@ // scrolling in that direction will scroll the body 'behind' the table of // contents. Fix this by preventing ever reaching the top or bottom of the // table of contents (by 1 pixel). - // + // // http://blog.christoffer.me/six-things-i-learnt-about-ios-safaris-rubber-band-scrolling/ $toc.on("touchstart.toc", function () { var $this = $(this), @@ -62,7 +70,7 @@ function openNavigation() { $html.addClass('toc-open'); - + toggleBackgroundVisiblity(false); updateAriaAttributes(); diff --git a/lib/assets/javascripts/_start-modules.js b/lib/assets/javascripts/_start-modules.js index f87afaf4..77ce4a47 100644 --- a/lib/assets/javascripts/_start-modules.js +++ b/lib/assets/javascripts/_start-modules.js @@ -4,6 +4,7 @@ //= require _modules/navigation //= require _modules/page-expiry //= require _modules/table-of-contents +//= require _modules/collapsible-navigation $(document).ready(function() { GOVUK.modules.start(); diff --git a/lib/assets/stylesheets/_core.scss b/lib/assets/stylesheets/_core.scss index 7e96c279..a2a6117f 100644 --- a/lib/assets/stylesheets/_core.scss +++ b/lib/assets/stylesheets/_core.scss @@ -30,6 +30,7 @@ $desktop-breakpoint: 992px !default; @import "modules/contribution-banner"; @import "modules/technical-documentation"; @import "modules/toc"; +@import "modules/collapsible"; @import "accessibility"; diff --git a/lib/assets/stylesheets/modules/_collapsible.scss b/lib/assets/stylesheets/modules/_collapsible.scss new file mode 100644 index 00000000..64b89a54 --- /dev/null +++ b/lib/assets/stylesheets/modules/_collapsible.scss @@ -0,0 +1,45 @@ +// Collapsible JS component styling, made for the navigation tree. +// These classes are added in table-of-contents.js. +// They should not be applied without the JS. + +.collapsible { + position: relative; +} +.collapsible__body { + display: none; + .collapsible.is-open & { + display: block + } +} +.collapsible__toggle { + position: absolute; + top: 0; + right: -25px; + width: 50px; + height: 40px; + overflow: hidden; + text-indent: -999em; + border: 0; + background: 0; + color: inherit; + padding: 0; + &:focus { + outline: 3px solid $focus-colour; + } +} +.collapsible__toggle-icon { + position: absolute; + top: 0; + right: 30px; + &::after { + content: ''; + display: block; + background: no-repeat file-url('arrow-down.svg') center center; + background-size: 18px auto; + width: 20px; + height: 40px; + } + .collapsible.is-open &::after { + background-image: file-url('arrow-up.svg'); + } +} diff --git a/lib/assets/stylesheets/modules/_toc.scss b/lib/assets/stylesheets/modules/_toc.scss index be21991e..ad1de4da 100644 --- a/lib/assets/stylesheets/modules/_toc.scss +++ b/lib/assets/stylesheets/modules/_toc.scss @@ -27,7 +27,7 @@ a:link, a:visited { display: block; - padding: 8px $gutter-half; + padding: 8px 40px 8px $gutter-half; margin: 0 $gutter-half * -1; border-left: 5px solid transparent; @@ -46,11 +46,6 @@ } @include media(tablet) { - // Level 2 - > ul > li > ul { - margin-bottom: 20px; - } - // Level 3 li li li { a:link, a:visited { diff --git a/lib/govuk_tech_docs/table_of_contents/heading.rb b/lib/govuk_tech_docs/table_of_contents/heading.rb index 738eebd0..3bd94198 100644 --- a/lib/govuk_tech_docs/table_of_contents/heading.rb +++ b/lib/govuk_tech_docs/table_of_contents/heading.rb @@ -1,10 +1,11 @@ module GovukTechDocs module TableOfContents class Heading - def initialize(element_name:, text:, attributes:) + def initialize(element_name:, text:, attributes:, page_url: '') @element_name = element_name @text = text @attributes = attributes + @page_url = page_url end def size @@ -12,7 +13,7 @@ def size end def href - '#' + @attributes['id'] + @page_url + '#' + @attributes['id'] end def title diff --git a/lib/govuk_tech_docs/table_of_contents/headings_builder.rb b/lib/govuk_tech_docs/table_of_contents/headings_builder.rb index 65d9fce5..e0f737dc 100644 --- a/lib/govuk_tech_docs/table_of_contents/headings_builder.rb +++ b/lib/govuk_tech_docs/table_of_contents/headings_builder.rb @@ -1,8 +1,9 @@ module GovukTechDocs module TableOfContents class HeadingsBuilder - def initialize(html) + def initialize(html, url) @html = html + @url = url end def headings @@ -10,7 +11,8 @@ def headings Heading.new( element_name: element.node_name, text: element.content, - attributes: convert_nokogiri_attr_objects_to_hashes(element.attributes) + attributes: convert_nokogiri_attr_objects_to_hashes(element.attributes), + page_url: @url ) end end diff --git a/lib/govuk_tech_docs/table_of_contents/helpers.rb b/lib/govuk_tech_docs/table_of_contents/helpers.rb index 281d104c..110d18e3 100644 --- a/lib/govuk_tech_docs/table_of_contents/helpers.rb +++ b/lib/govuk_tech_docs/table_of_contents/helpers.rb @@ -7,16 +7,63 @@ module GovukTechDocs module TableOfContents module Helpers - def table_of_contents(html, max_level: nil) - headings = HeadingsBuilder.new(html).headings + def single_page_table_of_contents(html, url: '', max_level: nil) + headings = HeadingsBuilder.new(html, url).headings if headings.none? { |heading| heading.size == 1 } - raise "No H1 tag found. You have to at least add one H1 heading to the page." + raise "No H1 tag found. You have to at least add one H1 heading to the page: " + url end tree = HeadingTreeBuilder.new(headings).tree HeadingTreeRenderer.new(tree, max_level: max_level).html end + + def multi_page_table_of_contents(resources, current_page, config, current_page_html = nil) + # Only parse top level html files + # Sorted by weight frontmatter + resources = resources + .select { |r| r.path.end_with?(".html") && (r.parent.nil? || r.parent.url == "/") } + .sort_by { |r| [r.data.weight ? 0 : 1, r.data.weight || 0] } + + render_page_tree(resources, current_page, config, current_page_html) + end + + def render_page_tree(resources, current_page, config, current_page_html) + # Sort by weight frontmatter + resources = resources + .sort_by { |r| [r.data.weight ? 0 : 1, r.data.weight || 0] } + output = ''; + resources.each do |resource| + # Reuse the generated content for the active page + # If we generate it twice it increments the heading ids + content = + if current_page.url == resource.url && current_page_html + current_page_html + else + resource.render(layout: false) + end + # Avoid redirect pages + next if content.include? "http-equiv=refresh" + # If this page has children, just print the title and recursively + # render the children. + # If not, print the heading structure. + # We avoid printing the children of the root index.html as it is the + # parent of every other top level file. + if resource.children.any? && resource.url != "/" + output += %{' + else + output += + single_page_table_of_contents( + content, + url: resource.url, + max_level: config[:tech_docs][:max_toc_heading_level] + ) + end + end + output + end end end end diff --git a/lib/source/images/arrow-down.svg b/lib/source/images/arrow-down.svg new file mode 100644 index 00000000..1f9e98f7 --- /dev/null +++ b/lib/source/images/arrow-down.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/lib/source/images/arrow-up.svg b/lib/source/images/arrow-up.svg new file mode 100644 index 00000000..dbf56a6a --- /dev/null +++ b/lib/source/images/arrow-up.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/lib/source/layouts/core.erb b/lib/source/layouts/core.erb index 6085c0e3..0783b30b 100644 --- a/lib/source/layouts/core.erb +++ b/lib/source/layouts/core.erb @@ -45,7 +45,7 @@
-
diff --git a/lib/source/layouts/layout.erb b/lib/source/layouts/layout.erb index edece68b..d70e3147 100644 --- a/lib/source/layouts/layout.erb +++ b/lib/source/layouts/layout.erb @@ -5,10 +5,12 @@ wrap_layout :core do content_for(:toc_module, "in-page-navigation") content_for :sidebar do - table_of_contents( - html, - max_level: config[:tech_docs][:max_toc_heading_level] - ) + if config[:tech_docs][:multipage_nav] %> + <%= multi_page_table_of_contents(sitemap.resources, current_page, config, html) %> + <% else %> + <%= single_page_table_of_contents(html, max_level: config[:tech_docs][:max_toc_heading_level]) %> + <% end %> + <% end html diff --git a/spec/features/integration_spec.rb b/spec/features/integration_spec.rb index f88b8872..3c30e34d 100644 --- a/spec/features/integration_spec.rb +++ b/spec/features/integration_spec.rb @@ -12,6 +12,7 @@ then_there_is_a_heading then_there_is_a_source_footer then_the_page_highlighted_in_the_navigation_is("Documentation") + then_there_are_navigation_headings_from_other_pages and_there_are_proper_meta_tags and_redirects_are_working @@ -57,6 +58,10 @@ def then_the_page_highlighted_in_the_navigation_is(link_label) expect(page.find('li.active a').text).to eq(link_label) end + def then_there_are_navigation_headings_from_other_pages + expect(page).to have_css '.toc__list a', text: 'A subheader' + end + def when_i_view_a_proxied_page visit '/a-proxied-page.html' end diff --git a/spec/table_of_contents/headings_builder_spec.rb b/spec/table_of_contents/headings_builder_spec.rb index d9c6c5b8..1c151346 100644 --- a/spec/table_of_contents/headings_builder_spec.rb +++ b/spec/table_of_contents/headings_builder_spec.rb @@ -9,8 +9,9 @@

Get some apples..

Pears

} + url = '' - headings = described_class.new(html).headings + headings = described_class.new(html, url).headings expect(headings).to eq([ GovukTechDocs::TableOfContents::Heading.new(element_name: 'h1', text: 'Apples', attributes: { 'id' => 'apples' }), diff --git a/spec/table_of_contents/helpers_spec.rb b/spec/table_of_contents/helpers_spec.rb index ba4001ce..85975f72 100644 --- a/spec/table_of_contents/helpers_spec.rb +++ b/spec/table_of_contents/helpers_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe GovukTechDocs::TableOfContents::Helpers do - describe '#table_of_contents' do + describe '#single_page_table_of_contents' do class Subject include GovukTechDocs::TableOfContents::Helpers end @@ -17,7 +17,7 @@ class Subject

Get some apples..

} - expected_table_of_contents = %{ + expected_single_page_table_of_contents = %{ } - expect(subject.table_of_contents(html).strip).to eq(expected_table_of_contents.strip) + expect(subject.single_page_table_of_contents(html).strip).to eq(expected_single_page_table_of_contents.strip) end it 'builds a table of contents from html when headings suddenly change by more than one size' do @@ -46,7 +46,7 @@ class Subject

Bread

} - expected_table_of_contents = %{ + expected_single_page_table_of_contents = %{ } - expect(subject.table_of_contents(html).strip).to eq(expected_table_of_contents.strip) + expect(subject.single_page_table_of_contents(html).strip).to eq(expected_single_page_table_of_contents.strip) end it 'builds a table of contents from HTML without an h1' do @@ -79,7 +79,148 @@ class Subject

Apples

} - expect { subject.table_of_contents(html).strip }.to raise_error(RuntimeError) + expect { subject.single_page_table_of_contents(html).strip }.to raise_error(RuntimeError) + end + end + + describe '#multi_page_table_of_contents' do + class Subject + include GovukTechDocs::TableOfContents::Helpers + end + + class FakeData + attr_reader :weight + attr_reader :title + def initialize(weight = nil, title = nil) + @weight = weight + @title = title + end + end + + class FakeResource + attr_reader :url + attr_reader :data + attr_reader :parent + attr_reader :children + def initialize(url, html, weight = nil, title = nil, parent = nil, children = []) + @url = url + @html = html + @parent = parent + @children = children + @data = FakeData.new(weight, title) + end + + def path + @url + end + + def render(_layout) + @html + end + + def add_children(children) + @children.concat children + end + end + + subject { Subject.new } + + it 'builds a table of contents from several page resources' do + resources = [] + resources[0] = FakeResource.new('/index.html', '

Heading one

Heading two

', 10, 'Index'); + resources[1] = FakeResource.new('/a.html', '

Heading one

Heading two

', 10, 'Sub page A', resources[0]); + resources[2] = FakeResource.new('/b.html', '

Heading one

Heading two

', 20, 'Sub page B', resources[0]); + resources[0].add_children [resources[1], resources[2]] + + current_page = double("current_page", + data: double("page_frontmatter", description: "The description.", title: "The Title"), + url: "/index.html", + metadata: { locals: {} }) + + current_page_html = '

Heading one

Heading two

'; + + config = { + tech_docs: { + max_toc_heading_level: 3 + } + } + + expected_multi_page_table_of_contents = %{ + + } + + expect(subject.multi_page_table_of_contents(resources, current_page, config, current_page_html).strip).to eq(expected_multi_page_table_of_contents.strip) + end + + it 'builds a table of contents from a single page resources' do + resources = [] + resources.push FakeResource.new('/index.html', '

Heading one

Heading two

Heading one

Heading two

Heading one

Heading two

'); + + current_page = double("current_page", + data: double("page_frontmatter", description: "The description.", title: "The Title"), + url: "/index.html", + metadata: { locals: {} }) + + current_page_html = '

Heading one

Heading two

Heading one

Heading two

Heading one

Heading two

'; + + config = { + tech_docs: { + max_toc_heading_level: 3, + multipage_nav: true + }, + } + + expected_multi_page_table_of_contents = %{ + + } + + expect(subject.multi_page_table_of_contents(resources, current_page, config, current_page_html).strip).to eq(expected_multi_page_table_of_contents.strip) end end end