diff --git a/app/components/header/navigation_component.html.erb b/app/components/header/navigation_component.html.erb
index 9a200bff04..880ad99243 100644
--- a/app/components/header/navigation_component.html.erb
+++ b/app/components/header/navigation_component.html.erb
@@ -1,7 +1,33 @@
-
+ <% end %>
+<% end %>
+
+
+<%= tag.div id: "secondary-navigation", class: "desktop-menu-container hidden-mobile", data: { "navigation-target": "desktop", action: "click->navigation#handleNavMenuClick" }, "aria-label": "Secondary navigation", role: "navigation" do %>
+
+ <% all_resources.each do |resource| %>
+ <% if resource.children? %>
+ <%= category_list(resource, :desktop, css_class: "category-links-list hidden-menu") %>
+ <% end %>
+ <% end %>
+
+
+
+ <% all_resources.each do |resource| %>
+ <% if resource.subcategories.present? %>
+ <% resource.subcategories.each do |subcategory| %>
+ <%= page_list(resource, subcategory, :desktop, css_class: "page-links-list hidden-menu") %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+
+
+
+
+<% end %>
diff --git a/app/components/header/navigation_component.rb b/app/components/header/navigation_component.rb
index d2faa1f902..24d3a90fd7 100644
--- a/app/components/header/navigation_component.rb
+++ b/app/components/header/navigation_component.rb
@@ -1,12 +1,13 @@
module Header
class NavigationComponent < ViewComponent::Base
- attr_reader :resources, :extra_resources
+ attr_reader :resources, :extra_resources, :front_matter
- def initialize(resources: nil, extra_resources: {})
+ def initialize(resources: nil, extra_resources: {}, front_matter: {})
super
@resources = resources
@extra_resources = build_additional_resource_nodes(extra_resources)
+ @front_matter = front_matter
end
def before_render
@@ -19,14 +20,130 @@ def all_resources
private
- def nav_link(link_text, link_path)
- tag.li class: class_name(link_path) do
- link_to_unless_current(link_text, link_path, class: "link--black link--no-underline") { tag.div(link_text) }
+ def corresponding_mode(mode)
+ mode == :mobile ? :desktop : :mobile
+ end
+
+ def nav_link(resource, mode)
+ title = resource.title
+ path = resource.path
+ li_id = "#{path.parameterize}-#{mode}"
+ corresponding_li_id = "#{path.parameterize}-#{corresponding_mode(mode)}"
+ child_menu_id = category_list_id(resource, mode)
+ corresponding_child_menu_id = category_list_id(resource, corresponding_mode(mode))
+ child_menu_ids = [child_menu_id, corresponding_child_menu_id].join(" ")
+ li_css = ("active" if uri_is_root?(path) || first_uri_segment_matches_link?(path)).to_s
+ show_dropdown = resource.children?
+ link_css = "menu-link link link--black link--no-underline"
+ aria_attributes = show_dropdown ? { expanded: false, controls: child_menu_ids } : {}
+ tag.li id: li_id, class: li_css, data: { "corresponding-id": corresponding_li_id, "child-menu-id": child_menu_id, "corresponding-child-menu-id": corresponding_child_menu_id, "direct-link": !show_dropdown, "toggle-secondary-navigation": show_dropdown, action: "keydown.tab->navigation#handleMenuTab" } do
+ safe_join([
+ link_to(path, class: link_css, aria: aria_attributes) do
+ safe_join([
+ tag.span(title, class: "menu-title"),
+ contracted_icon(visible: show_dropdown),
+ ])
+ end,
+ if mode == :mobile && resource.children?
+ [
+ row_break,
+ category_list(resource, mode, css_class: "category-links-list hidden-menu hidden-desktop"),
+ ]
+ end,
+ ])
+ end
+ end
+
+ def category_list(resource, mode, css_class:)
+ tag.ol(class: css_class, id: category_list_id(resource, mode)) do
+ safe_join(
+ [
+ resource.children_without_subcategory.map do |child_resource|
+ nav_link(child_resource, mode)
+ end,
+ resource.subcategories.map do |category|
+ category_link(category, resource, mode)
+ end,
+ view_all_link(resource, mode),
+ ],
+ )
+ end
+ end
+
+ def category_list_id(resource, mode)
+ "#{resource.path.parameterize}-categories-#{mode}"
+ end
+
+ def category_link(subcategory, resource, mode)
+ title = subcategory
+ li_id = "#{resource.path.parameterize}-#{title.parameterize}-#{mode}"
+ corresponding_li_id = "#{resource.path.parameterize}-#{title.parameterize}-#{corresponding_mode(mode)}"
+ child_menu_id = page_list_id(resource, subcategory, mode)
+ corresponding_child_menu_id = page_list_id(resource, subcategory, corresponding_mode(mode))
+ child_menu_ids = [child_menu_id, corresponding_child_menu_id].join(" ")
+ li_css = ("active" if subcategory == front_matter["subcategory"]).to_s
+ link_css = "menu-link link link--black link--no-underline btn-as-link"
+ aria_attributes = { expanded: false, controls: child_menu_ids }
+ tag.li id: li_id, class: li_css, data: { "corresponding-id": corresponding_li_id, "child-menu-id": child_menu_id, "corresponding-child-menu-id": corresponding_child_menu_id, "direct-link": false, action: "keydown.tab->navigation#handleMenuTab" } do
+ safe_join(
+ [
+ tag.button(type: "button", class: link_css, aria: aria_attributes) do
+ safe_join(
+ [
+ tag.span(title, class: "menu-title"),
+ contracted_icon(visible: true),
+ ],
+ )
+ end,
+ row_break,
+ if mode == :mobile
+ page_list(resource, subcategory, mode, css_class: "page-links-list hidden-menu hidden-desktop")
+ end,
+ ],
+ )
+ end
+ end
+
+ def page_list_id(resource, subcategory, mode)
+ "#{resource.path.parameterize}-#{subcategory.parameterize}-pages-#{mode}"
+ end
+
+ def page_list(resource, subcategory, mode, css_class:)
+ tag.ol(class: css_class, id: page_list_id(resource, subcategory, mode)) do
+ safe_join(
+ [
+ resource.children_in_subcategory(subcategory).map do |child_resource|
+ nav_link(child_resource, mode)
+ end,
+ ],
+ )
end
end
- def class_name(link_path)
- "active" if uri_is_root?(link_path) || first_uri_segment_matches_link?(link_path)
+ def view_all_link(resource, mode)
+ title = "View all in #{resource.title}"
+ path = resource.path
+ id = "menu-view-all-#{path.parameterize}-#{mode}"
+ li_css = "view-all #{'active' if uri_is_root?(path)}"
+ link_css = "menu-link link link--black"
+
+ tag.li class: li_css, data: { id: id, "direct-link": true } do
+ safe_join([
+ link_to(path, class: link_css) do
+ tag.span(title, class: "menu-title")
+ end,
+ ])
+ end
+ end
+
+ def row_break
+ tag.div(class: "break", "aria-hidden": true)
+ end
+
+ def contracted_icon(visible: true)
+ if visible
+ tag.span(class: "nav-icon nav-icon__contracted", aria: { hidden: true })
+ end
end
def uri_is_root?(link_path)
diff --git a/app/components/header_component.html.erb b/app/components/header_component.html.erb
index 907ac6c62e..e687d6e7bb 100644
--- a/app/components/header_component.html.erb
+++ b/app/components/header_component.html.erb
@@ -10,12 +10,15 @@
<%= render Header::ExtraNavigationComponent.new(search_input_id: "searchbox__input--desktop") %>
<%= render Header::LogoComponent.new %>
- <%= render Header::NavigationComponent.new %>
+ <%= render Header::NavigationComponent.new(front_matter: front_matter) %>
<% if breadcrumbs %>
diff --git a/app/components/header_component.rb b/app/components/header_component.rb
index 0afb69fdeb..d1ef126e7e 100644
--- a/app/components/header_component.rb
+++ b/app/components/header_component.rb
@@ -1,9 +1,10 @@
class HeaderComponent < ViewComponent::Base
- attr_reader :breadcrumbs
+ attr_reader :breadcrumbs, :front_matter
- def initialize(breadcrumbs: false)
+ def initialize(breadcrumbs: false, front_matter: {})
super
@breadcrumbs = breadcrumbs
+ @front_matter = front_matter
end
end
diff --git a/app/models/pages/navigation.rb b/app/models/pages/navigation.rb
index 13a2463e37..a75561e70c 100644
--- a/app/models/pages/navigation.rb
+++ b/app/models/pages/navigation.rb
@@ -54,6 +54,26 @@ def children
navigation.all_pages.select { |page| page.path.start_with?(path) && page.path != path }
end
+ def children?
+ children.any?
+ end
+
+ def children_without_subcategory
+ children.select { |page| page.subcategory.nil? }
+ end
+
+ def children_in_subcategory(subcategory)
+ children.select { |page| page.subcategory == subcategory }
+ end
+
+ def subcategories
+ children.map(&:subcategory).compact.uniq
+ end
+
+ def subcategories?
+ subcategories.any?
+ end
+
def menu?
@menu
end
diff --git a/app/views/layouts/category.html.erb b/app/views/layouts/category.html.erb
index c62edbed50..9dec41fc9f 100644
--- a/app/views/layouts/category.html.erb
+++ b/app/views/layouts/category.html.erb
@@ -18,7 +18,7 @@
<% grouped_categories(@page.path).each do |title, pages| %>
- <%= tag.h2(title, class: "heading--box-blue") %>
+ <%= tag.h2(title, id: title.parameterize, class: "heading--box-blue") %>
<%= tag.nav(aria: { label: "#{title} category" }, class: "category__nav-cards inset") do %>
<%= render(Categories::CardComponent.with_collection(pages, heading_tag: "h3")) %>
diff --git a/app/views/layouts/content.html.erb b/app/views/layouts/content.html.erb
index 68eaf26f54..297c255756 100644
--- a/app/views/layouts/content.html.erb
+++ b/app/views/layouts/content.html.erb
@@ -3,7 +3,7 @@
<%= render "sections/head" %>
<%= body_tag do %>
<%= render "sections/govuk_javascript" %>
- <%= render HeaderComponent.new(breadcrumbs: true) %>
+ <%= render HeaderComponent.new(breadcrumbs: true, front_matter: @front_matter) %>
<%= main_tag do %>
<%= render Content::HeroComponent.new(@front_matter) %>
diff --git a/app/views/pages/browse.html.erb b/app/views/pages/browse.html.erb
new file mode 100644
index 0000000000..ee52a8f664
--- /dev/null
+++ b/app/views/pages/browse.html.erb
@@ -0,0 +1,13 @@
+Menu
+<% Pages::Navigation.root_pages.each do |resource| %>
+
+ <%= link_to(resource.title, resource.path) %>
+
+ <% if resource.subcategories? %>
+
+ <% resource.subcategories.map do |category| %>
+ - <%= category %>
+ <% end %>
+
+ <% end %>
+<% end %>
diff --git a/app/webpacker/controllers/navigation_controller.js b/app/webpacker/controllers/navigation_controller.js
index 9b5815d3ef..475d9ccc4a 100644
--- a/app/webpacker/controllers/navigation_controller.js
+++ b/app/webpacker/controllers/navigation_controller.js
@@ -1,26 +1,13 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
- static targets = ['primary', 'nav', 'menu'];
+ static targets = ['primary', 'menu', 'nav', 'desktop'];
connect() {}
- toggleMenu(event) {
- event.preventDefault();
- event.stopPropagation();
-
- const toggle = event.target.closest('li');
- const secondary = event.target.closest('li').querySelector('ol.secondary');
-
- if (secondary.classList.contains(this.menuHiddenClass)) {
- this.expandMenu(secondary, toggle);
- } else {
- this.collapseMenu(secondary, toggle);
- }
- }
-
// toggles the entire nav on tablet/mobile
toggleNav(event) {
+ // fires when the user clicks on the "Menu" button (not a menu item)
event.preventDefault();
event.stopPropagation();
@@ -35,6 +22,178 @@ export default class extends Controller {
}
}
+ handleNavMenuClick(event) {
+ // fires when the user clicks on a navigation menu item
+ const item = event.target.closest('li');
+ if (!item) return;
+
+ const directLink = item.dataset.directLink === 'true';
+ if (directLink) return;
+
+ const correspondingItem = this.getTargetItem(item.dataset.correspondingId);
+ const childMenu = this.getTargetItem(item.dataset.childMenuId);
+ const correspondingChildMenu = this.getTargetItem(
+ item.dataset.correspondingChildMenuId,
+ );
+ const toggleSecondaryNavigation = item.dataset.toggleSecondaryNavigation;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (this.toggleIconExpanded(item)) {
+ this.toggleIconExpanded(correspondingItem);
+ this.showMenu(childMenu);
+ this.showMenu(correspondingChildMenu);
+ this.contractAndHideSiblingMenus(item, correspondingItem);
+ if (toggleSecondaryNavigation) {
+ this.expandSecondaryNavigation();
+ }
+ } else if (this.toggleIconContracted(item)) {
+ this.toggleIconContracted(correspondingItem);
+ this.contractAndHideChildMenu(childMenu);
+ this.contractAndHideChildMenu(correspondingChildMenu);
+ if (toggleSecondaryNavigation) {
+ this.contractSecondaryNavigation();
+ }
+ }
+ }
+
+ handleMenuTab(event) {
+ const item = event.target.closest('li');
+ if (!item || !item.classList.contains('selected')) return false;
+
+ const childMenu = this.getTargetItem(item.dataset.childMenuId);
+ const nextItem = childMenu.querySelector('li > .menu-link');
+ const correspondingChildMenu = this.getTargetItem(
+ item.dataset.correspondingChildMenuId,
+ );
+ const correspondingNextItem =
+ correspondingChildMenu.querySelector('li > .menu-link');
+
+ if (nextItem) {
+ nextItem.focus();
+ }
+
+ if (correspondingNextItem) {
+ correspondingNextItem.focus();
+ }
+
+ event.preventDefault();
+ }
+
+ getTarget(id) {
+ if (!id) return;
+
+ if (id.endsWith('-desktop')) {
+ return this.desktopTarget;
+ } else if (id.endsWith('-mobile')) {
+ return this.navTarget;
+ }
+ }
+
+ getTargetItem(id) {
+ if (id) {
+ return this.getTarget(id).querySelector('#' + id);
+ }
+ }
+
+ toggleIcon(item, expand = true) {
+ if (!item) return false;
+ const icon = item.querySelector('span.nav-icon');
+ const linkOrButton = item.querySelector('a, button');
+
+ if (!icon || !linkOrButton) return false;
+
+ const currentClass = expand ? 'nav-icon__contracted' : 'nav-icon__expanded';
+ const newClass = expand ? 'nav-icon__expanded' : 'nav-icon__contracted';
+
+ if (icon.classList.contains(currentClass)) {
+ icon.classList.replace(currentClass, newClass);
+ item.classList.toggle('selected', expand);
+ linkOrButton.ariaExpanded = expand;
+ return true;
+ }
+
+ return false;
+ }
+
+ toggleIconExpanded(item) {
+ return this.toggleIcon(item, true);
+ }
+
+ toggleIconContracted(item) {
+ return this.toggleIcon(item, false);
+ }
+
+ toggleMenuVisibility(menu, shouldHide = true) {
+ if (!menu) return false;
+
+ const currentlyHidden = menu.classList.contains('hidden-menu');
+
+ if (currentlyHidden === shouldHide) return false;
+
+ menu.classList.toggle('hidden-menu', shouldHide);
+ return true;
+ }
+
+ showMenu(menu) {
+ return this.toggleMenuVisibility(menu, false);
+ }
+
+ hideMenu(menu) {
+ return this.toggleMenuVisibility(menu, true);
+ }
+
+ expandSecondaryNavigation() {
+ this.desktopTarget.classList.add('expanded');
+ }
+
+ contractSecondaryNavigation() {
+ this.desktopTarget.classList.remove('expanded');
+ }
+
+ contractAndHideSiblingMenus(item) {
+ const self = this;
+ [].forEach.call(item.closest('ol').children, function (sibling) {
+ if (sibling !== item) {
+ if (self.toggleIconContracted(sibling)) {
+ const correspondingItem = self.getTargetItem(
+ sibling.dataset.correspondingId,
+ );
+
+ self.toggleIconContracted(correspondingItem);
+ self.contractAndHideChildItem(sibling);
+ self.contractAndHideChildItem(correspondingItem);
+ }
+ }
+ });
+ }
+
+ contractAndHideChildItem(item) {
+ if (!item) return;
+
+ const childMenu = this.getTargetItem(item.dataset.childMenuId);
+ const correspondingChildMenu = this.getTargetItem(
+ item.dataset.correspondingChildMenuId,
+ );
+ this.contractAndHideChildMenu(childMenu);
+ this.contractAndHideChildMenu(correspondingChildMenu);
+ }
+
+ contractAndHideChildMenu(menu) {
+ if (!menu) return;
+
+ const self = this;
+ self.hideMenu(menu);
+
+ [].forEach.call(menu.children, function (childMenuItem) {
+ if (childMenuItem) {
+ self.toggleIconContracted(childMenuItem);
+ self.contractAndHideChildItem(childMenuItem);
+ }
+ });
+ }
+
collapseMenu(menu, item) {
menu.classList.add(this.menuHiddenClass);
diff --git a/app/webpacker/images/navigation/arrow-down-black.svg b/app/webpacker/images/navigation/arrow-down-black.svg
new file mode 100644
index 0000000000..06a41dd41a
--- /dev/null
+++ b/app/webpacker/images/navigation/arrow-down-black.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/webpacker/images/navigation/arrow-down-pink.svg b/app/webpacker/images/navigation/arrow-down-pink.svg
new file mode 100644
index 0000000000..8bbb4141d5
--- /dev/null
+++ b/app/webpacker/images/navigation/arrow-down-pink.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/webpacker/images/navigation/arrow-right-black.svg b/app/webpacker/images/navigation/arrow-right-black.svg
new file mode 100644
index 0000000000..b110cae051
--- /dev/null
+++ b/app/webpacker/images/navigation/arrow-right-black.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/webpacker/images/navigation/arrow-right-white.svg b/app/webpacker/images/navigation/arrow-right-white.svg
new file mode 100644
index 0000000000..eac1afec3b
--- /dev/null
+++ b/app/webpacker/images/navigation/arrow-right-white.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/webpacker/images/navigation/arrow-up-black.svg b/app/webpacker/images/navigation/arrow-up-black.svg
new file mode 100644
index 0000000000..ab436d6a5e
--- /dev/null
+++ b/app/webpacker/images/navigation/arrow-up-black.svg
@@ -0,0 +1,11 @@
+
diff --git a/app/webpacker/images/navigation/arrow-up-pink.svg b/app/webpacker/images/navigation/arrow-up-pink.svg
new file mode 100644
index 0000000000..7b26ac417e
--- /dev/null
+++ b/app/webpacker/images/navigation/arrow-up-pink.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/webpacker/images/navigation/cross.svg b/app/webpacker/images/navigation/cross.svg
new file mode 100644
index 0000000000..e7585b4d5d
--- /dev/null
+++ b/app/webpacker/images/navigation/cross.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/webpacker/packs/js_enabled.js b/app/webpacker/packs/js_enabled.js
index 664271c87f..3153bcd577 100644
--- a/app/webpacker/packs/js_enabled.js
+++ b/app/webpacker/packs/js_enabled.js
@@ -1,2 +1,2 @@
-document.documentElement.classList.toggle('js-enabled');
-document.body.classList.toggle('js-enabled');
+document.documentElement.classList.add('js-enabled');
+document.body.classList.add('js-enabled');
diff --git a/app/webpacker/styles/header.scss b/app/webpacker/styles/header.scss
index 0ac1811146..9b4b5ec8d4 100644
--- a/app/webpacker/styles/header.scss
+++ b/app/webpacker/styles/header.scss
@@ -5,7 +5,6 @@
@import "header/menu-button";
body > header {
-
// mobile (default) layout
display: grid;
grid-template-columns: 1fr;
@@ -54,3 +53,28 @@ body > header {
}
}
}
+
+.desktop-menu-container {
+ grid-column-start: 1;
+ grid-column-end: -1;
+ display: grid;
+ grid-template-columns: [left] 100px [category-links-start] 1fr [page-links-start] 1fr [hot-links-start] 1fr [content-end] 100px [right];
+ grid-template-rows: [main-start] 1fr [main-end];
+ background: #F0F0F0;
+}
+
+.category-links {
+ grid-column-start: category-links-start;
+ grid-column-end: page-links-start;
+}
+
+.page-links {
+ grid-column-start: page-links-start;
+ grid-column-end: hot-links-start;
+ border-left: dotted lightgrey 1px;
+}
+
+.key-links {
+ grid-column-start: hot-links-start;
+ grid-column-end: content-end;
+}
diff --git a/app/webpacker/styles/header/navigation.scss b/app/webpacker/styles/header/navigation.scss
index 01782a00b8..09ee5272a4 100644
--- a/app/webpacker/styles/header/navigation.scss
+++ b/app/webpacker/styles/header/navigation.scss
@@ -1,123 +1,458 @@
@mixin list-item-nav {
@include font-size("xsmall");
-
padding: .5em;
-
- a {
- display: inline-block;
- }
}
@mixin active-identifier($colour) {
- border-left: 6px solid $colour;
+ border-left: 5px solid $colour;
border-bottom: none;
@include mq($from: desktop) {
- border-bottom: 6px solid $colour;
+ border-bottom: 5px solid $colour;
border-left: none;
}
}
-body > header nav {
- // general
- display: flex;
- flex-grow: 1;
-
- ol.primary {
- list-style: none;
- display: flex;
-
- > li {
- @include list-item-nav;
- @include reset;
- line-height: 1.2;
- margin-bottom: 0;
- }
+body > header {
+ button.btn-as-link {
+ border-style: none;
+ font: inherit;
+ text-align: start;
+ -moz-appearance: none;
+ line-height: 1.375;
}
- li > a {
+ .menu-link {
cursor: pointer;
}
- // mobile and tablet
- @include mq($until: desktop) {
- flex-direction: column-reverse;
-
- &.hidden-mobile {
- display: none;
- }
+ #primary-navigation {
+ // general
+ display: flex;
+ flex-grow: 1;
ol.primary {
- flex-direction: column;
- margin: 0;
- padding: 0 1em;
- background: $cta-green;
+ list-style: none;
+ display: flex;
+
+ li {
+ @include list-item-nav;
+ @include reset;
+ line-height: 1.2;
+ margin-bottom: 0;
+ }
+
+ // general (mobile and desktop)
+ .nav-icon {
+ &__contracted {
+ background-image: url("../images/navigation/arrow-down-pink.svg");
+ }
+
+ &__expanded {
+ background-image: url("../images/navigation/arrow-up-pink.svg");
+ }
+ }
+
+ // mobile only
+ @include mq($until: desktop) {
+
+ flex-direction: column;
+ flex-wrap: wrap;
+ margin: 0;
+ padding: 0 1em;
+
+ li {
+ // NB: this is for all - elements in the mobile menu
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 1em;
+
+ .break {
+ flex-basis: 100%;
+ height: 2px;
+ padding: 0;
+ margin: 0;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ // root level item only
+ > li {
+ border-bottom: solid 1px #B7B9BB;
+
+ &:last-child {
+ border-bottom: solid 1px #B7B9BB;
+ }
+
+ > .menu-link {
+ font-weight: bold;
+ border-left: none;
+ padding-left: 0.625em;
+
+ .nav-icon {
+ &__contracted {
+ background-image: url("../images/navigation/arrow-down-black.svg");
+ }
+
+ &__expanded {
+ background-image: url("../images/navigation/arrow-up-black.svg");
+ }
+ }
+
+ &:focus {
+ .nav-icon {
+ &__contracted {
+ background-image: url("../images/navigation/arrow-down-pink.svg");
+ }
- > li {
- border-bottom: 1px solid $white;
+ &__expanded {
+ background-image: url("../images/navigation/arrow-up-pink.svg");
+ }
+ }
+ }
+ }
+ }
+
+ // category-level item only
+ ol.category-links-list > li > .menu-link {
+ > .nav-icon {
+ &__contracted {
+ background-image: url("../images/navigation/arrow-down-pink.svg");
+ }
+
+ &__expanded {
+ background-image: url("../images/navigation/arrow-up-black.svg");
+ }
+ }
+
+ &:focus {
+ .nav-icon {
+ &__contracted {
+ background-image: url("../images/navigation/arrow-down-pink.svg");
+ }
+
+ &__expanded {
+ background-image: url("../images/navigation/arrow-up-pink.svg");
+ }
+ }
+ }
+ }
+
+ ol.category-links-list > li.selected > .menu-link {
+ &:focus {
+ color: $black;
+
+ .nav-icon {
+ &__contracted {
+ background-image: url("../images/navigation/arrow-down-pink.svg");
+ }
+
+ &__expanded {
+ background-image: url("../images/navigation/arrow-up-black.svg");
+ }
+ }
+ }
+ }
+
+ // all mobile menu links
+ .menu-link {
+ display: flex;
+ justify-content: space-between;
+ align-items: start;
- &:last-child {
- border-bottom: none;
+ // NB: this is for all elements
+ padding: 0.875em 1.25em;
+ flex-basis: 80px; // NB: setting this too large can cause some
+ // elements to unexpectedly increase in height
+ flex-grow: 1;
}
- &.active {
- background-image: url("../images/icon-arrow-right.png");
- background-position: left;
- background-repeat: no-repeat;
- padding-left: 1rem;
+ .nav-icon {
+ padding-right: 0;
}
+ } // mobile
- > a,
- div {
- display: block;
- padding: 1em 0;
- color: $white;
- font-weight: bold;
- font-size: 16px;
+ // desktop only
+ @include mq($from: desktop) {
+ flex-grow: 1;
+ align-items: flex-end;
+ justify-content: flex-end;
+ margin: 0 1em;
+ padding: 0 1em;
+
+ > li {
+ position: relative;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+
+ > .menu-link {
+ border-bottom: solid 4px transparent;
+ }
+
+ > .menu-link:hover {
+ border-bottom: solid 4px $black;
+ }
+
+ > .menu-link:focus {
+ box-shadow: none;
+ outline: none;
+ border-bottom: solid 4px $pink;
+ }
+
+ &.selected > .menu-link {
+ border-bottom: solid 4px $pink;
+ }
+
+ &.down {
+ background-color: $grey;
+ border-bottom: 5px solid $grey;
+ }
+
+
+ a,
+ button.btn-as-link,
+ div {
+ line-height: 1.5;
+ }
+
+ .break {
+ display: none;
+ }
+ }
+
+
+ .menu-link {
+ display: inline-grid;
+ grid-template-columns: minmax(min-content, max-content) min-content;
+ grid-template-rows: 1fr;
+ grid-column-gap: 0;
+ grid-row-gap: 0;
+ padding: 0.755em 0.75em;
+ margin: 0.75em 0;
+
+ .menu-title {
+ grid-area: 1 / 1 / 2 / 2;
+ display: block;
+ }
+
+ .nav-icon {
+ grid-area: 1 / 2 / 2 / 3;
+ display: block;
+ }
+ }
+ } // desktop
+ } // ol.primary
+
+ ol.category-links-list {
+ // mobile and tablet
+ @include mq($until: desktop) {
+ padding-top: 6px;
+ padding-left: 0;
+ flex-basis: 100%;
+
+ > li.selected {
+ background: $pink;
+
+ > a, button.btn-as-link {
+ font-weight: bold;
+ background: transparent;
+ }
+ }
+
+ > li.view-all {
+ background: #ffffff;
+ border-left: none;
+ }
+
+ > li {
+ background: #F0F0F0;
+ font-size: 1em;
+ font-weight: normal;
+ border-left: solid $pink 4px;
+ margin-bottom: 4px;
}
}
}
- }
- // desktop and wide
- @include mq($from: desktop) {
- ol.primary {
- flex-grow: 1;
- align-items: flex-end;
- justify-content: flex-end;
- margin: 0 1em;
- padding: 0 1em;
+ ol.page-links-list {
+ @include mq($until: desktop) {
+ padding: 0;
+ flex-basis: 100%;
- > li {
- position: relative;
- flex-direction: row;
- border-bottom: 6px solid $white;
- padding: 1em 1em 1.5em;
+ > li {
+ background: #ffffff;
+ border-bottom: solid 1px #B7B9BB;
+ }
- &.active {
- border-bottom: 6px solid $purple;
+ > li.selected {
+ > a, button.btn-as-link {
+ font-weight: bold;
+ }
}
+ }
+ }
+
+ // mobile and tablet
+ @include mq($until: desktop) {
+ flex-direction: column-reverse;
+ }
+
+ // desktop and wide screen
+ @include mq($from: desktop) {
+ .hidden-desktop {
+ display: none;
+ }
+ }
+ }
+
+ // NB: desktop only - no mobile version of secondary menu
+ #secondary-navigation {
+ @include mq($from: desktop) {
+ &.expanded {
+ padding-top: 20px;
+ padding-bottom: 20px;
+ }
+
+ ol.category-links-list, ol.page-links-list {
+ display: flex;
+ list-style: none;
+ flex-direction: column;
+ margin: 0;
+ padding: 0.8em 0 0;
+ background: #F0F0F0;
+
+ > li {
+ @include list-item-nav;
+ @include reset;
- &.down {
- background-color: $grey;
- border-bottom: 6px solid $grey;
+ button.menu-link {
+ width: 100%;
+ }
+
+ .menu-link {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.63em 0.94em;
+ border-left: solid transparent 4px;
+
+ &:focus {
+ .nav-icon {
+ &__contracted, &__expanded {
+ background-image: url("../images/navigation/arrow-right-white.svg");
+ }
+ }
+ }
+
+ &:hover {
+ color: $black;
+ background: #DEDEDE;
+
+ .nav-icon {
+ &__contracted, &__expanded {
+ background-image: url("../images/navigation/arrow-right-black.svg");
+ }
+ }
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &.active {
+ color: $black;
+ background: #DEDEDE;
+ }
+
+ .menu-title {
+ display: block;
+ font-size: 1em;
+ padding-left: 0;
+ }
+ }
+
+ .break {
+ display: none;
+ }
}
- a {
- &:hover,
+ > li.selected > .menu-link {
+ border-left: solid $pink 4px;
+ background: $white;
+
+ &:hover {
+ color: $black;
+ background: #DEDEDE;
+
+ .nav-icon {
+ &__contracted, &__expanded {
+ background-image: url("../images/navigation/arrow-right-black.svg");
+ }
+ }
+ }
+
&:focus {
- border-bottom: 4px solid $black;
+ color: $white;
+ background: $black;
+
+ .nav-icon {
+ &__contracted, &__expanded {
+ background-image: url("../images/navigation/arrow-right-white.svg");
+ }
+ }
}
}
+ }
+
+ ol.page-links-list {
+ > li {
+ border-left: none;
+ }
+ }
- a,
- div {
- line-height: 1.5;
- display: inline-block;
- padding: 0 .2em;
- border-bottom: 4px solid $white;
+ .nav-icon {
+ padding-right: 0;
+ width: 12px;
+ height: 21px;
+ margin-top: 0;
+
+ &__contracted, &__expanded {
+ background-image: url("../images/navigation/arrow-right-black.svg");
}
}
}
}
+
+ // NB: this section must appear below main menu definitions and applies to both mobile and desktop menus
+ #primary-navigation, #secondary-navigation {
+ ol.hidden-menu {
+ display: none;
+ }
+
+ @include mq($until: desktop) {
+ &.hidden-mobile {
+ display: none;
+ }
+ }
+
+ .nav-icon {
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: auto;
+ display: block;
+ width: 21px;
+ height: 21px;
+ margin-left: 0.25em;
+ margin-right: 0.25em;
+ }
+ }
}
+
+
diff --git a/app/webpacker/styles/links-and-buttons.scss b/app/webpacker/styles/links-and-buttons.scss
index 9647636bc5..671177d941 100644
--- a/app/webpacker/styles/links-and-buttons.scss
+++ b/app/webpacker/styles/links-and-buttons.scss
@@ -51,6 +51,12 @@
}
}
+ol.primary > li {
+ &:focus {
+ border: dotted red 1px;
+ }
+}
+
.link--chevron {
text-decoration: none;
padding-right: .3em;
diff --git a/app/webpacker/styles/utility.scss b/app/webpacker/styles/utility.scss
index 2ca09d831d..359225d941 100644
--- a/app/webpacker/styles/utility.scss
+++ b/app/webpacker/styles/utility.scss
@@ -93,6 +93,20 @@ $chevron-direction-map: (
}
/* stylelint-enable */
+html.js-enabled {
+ // hide components if javascript is enabled
+ .hidden-when-js-enabled {
+ display: none;
+ }
+}
+
+html:not(.js-enabled) {
+ // hide components if javascript is disabled
+ .hidden-when-js-disabled {
+ display: none;
+ }
+}
+
// Mixins for targetting specific legacy/quirky/inferior browsers
@mixin safari-only {
diff --git a/config/routes.rb b/config/routes.rb
index 4db24e5e50..bb26ac1f3f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -74,6 +74,7 @@
get "/privacy-policy", to: "pages#privacy_policy", as: :privacy_policy
get "/cookies", to: "pages#cookies", as: :cookies
get "/session-expired", to: "pages#session_expired", as: :session_expired
+ get "/browse", to: "pages#browse", as: :browse
get "/values", to: "pages#values", as: :values
get "/welcome", to: "pages#welcome", as: :welcome_guide
diff --git a/spec/components/header/navigation_component_spec.rb b/spec/components/header/navigation_component_spec.rb
index d0111ad666..51bf71885f 100644
--- a/spec/components/header/navigation_component_spec.rb
+++ b/spec/components/header/navigation_component_spec.rb
@@ -4,8 +4,29 @@
# these are built from the markdown frontmatter
subject! { render_inline(component) }
+ let(:primary_nav) do
+ {
+ "/page-one" => { title: "Page one", navigation: 1 },
+ "/page-two" => { title: "Page two", navigation: 2 },
+ "/page-three" => { title: "Page three", navigation: 3 },
+ "/page-four" => { title: "Page four", navigation: 4 },
+ "/page-five" => { title: "Page five", navigation: 5, menu: true },
+ "/page-six" => { title: "Page six", navigation: 6, menu: true },
+ "/page-seven" => { title: "Page seven", navigation: 7 },
+ }
+ end
+
+ let(:page_five_subpages) do
+ {
+ "/page-five/part-1" => { title: "Page five: part 1", subcategory: "category 1", navigation: 5.1 },
+ "/page-five/part-2" => { title: "Page five: part 2", subcategory: "category 1", navigation: 5.2 },
+ "/page-five/part-3" => { title: "Page five: part 3", subcategory: "category 2", navigation: 5.3 },
+ "/page-seven/part-0" => { title: "Page seven: part 0", subcategory: nil, navigation: 7.1 },
+ }
+ end
+
let(:resources) do
- [OpenStruct.new(path: "/one", title: "One"), OpenStruct.new(path: "/two", title: "Two")]
+ Pages::Navigation.new(primary_nav.merge(page_five_subpages)).nodes
end
# these are passed in as an arg and represent pages that aren't Markdown
@@ -28,4 +49,46 @@
specify "extra resources are last" do
expect(component.all_resources.map(&:title)).to end_with(extra_resources.values)
end
+
+ context "when using a desktop browser" do
+ it "renders a dropdown menu for category links" do
+ expect(page).to have_css("#secondary-navigation > div.category-links > ol.category-links-list > li[id='page-five-category-1-desktop'] > button")
+ expect(page).to have_css("#secondary-navigation > div.category-links > ol.category-links-list > li[id='page-five-category-2-desktop'] > button")
+ end
+
+ it "renders a dropdown menu for page links" do
+ expect(page).to have_css("#secondary-navigation > div.page-links > ol.page-links-list > li[id='page-five-part-1-desktop'] > a")
+ expect(page).to have_css("#secondary-navigation > div.page-links > ol.page-links-list > li[id='page-five-part-2-desktop'] > a")
+ expect(page).to have_css("#secondary-navigation > div.page-links > ol.page-links-list > li[id='page-five-part-3-desktop'] > a")
+ end
+
+ it "renders a view all link" do
+ expect(page).to have_css("#secondary-navigation > div.category-links > ol.category-links-list > li[data-id='menu-view-all-page-five-desktop'] > a")
+ end
+
+ it "renders uncategorised links" do
+ expect(page).to have_css("#secondary-navigation > div.category-links > ol.category-links-list > li[id='page-seven-part-0-desktop'] > a")
+ end
+ end
+
+ context "when using a mobile browser" do
+ it "renders a dropdown menu for category links" do
+ expect(page).to have_css("#primary-navigation > ol.primary > li[id='page-five-mobile'] > ol.category-links-list > li[id='page-five-category-1-mobile'] > button")
+ expect(page).to have_css("#primary-navigation > ol.primary > li[id='page-five-mobile'] > ol.category-links-list > li[id='page-five-category-2-mobile'] > button")
+ end
+
+ it "renders a dropdown menu for page links" do
+ expect(page).to have_css("#primary-navigation > ol.primary > li[id='page-five-mobile'] > ol.category-links-list > li[id='page-five-category-1-mobile'] > ol.page-links-list > li[id='page-five-part-1-mobile'] > a")
+ expect(page).to have_css("#primary-navigation > ol.primary > li[id='page-five-mobile'] > ol.category-links-list > li[id='page-five-category-1-mobile'] > ol.page-links-list > li[id='page-five-part-2-mobile'] > a")
+ expect(page).to have_css("#primary-navigation > ol.primary > li[id='page-five-mobile'] > ol.category-links-list > li[id='page-five-category-2-mobile'] > ol.page-links-list > li[id='page-five-part-3-mobile'] > a")
+ end
+
+ it "renders a view all link" do
+ expect(page).to have_css("#primary-navigation > ol.primary > li[id='page-five-mobile'] > ol.category-links-list > li[data-id='menu-view-all-page-five-mobile'] > a")
+ end
+
+ it "renders uncategorised links" do
+ expect(page).to have_css("#primary-navigation > ol.primary > li[id='page-seven-mobile'] > ol.category-links-list > li[id='page-seven-part-0-mobile'] > a")
+ end
+ end
end
diff --git a/spec/features/content_pages_spec.rb b/spec/features/content_pages_spec.rb
index e87232deef..caf67d4846 100644
--- a/spec/features/content_pages_spec.rb
+++ b/spec/features/content_pages_spec.rb
@@ -134,7 +134,7 @@ def css_to_xpath(css_selector)
.reject { |href| href.start_with?(Regexp.union("http:", "https:", "tel:", "mailto:")) }
.reject { |href| href.start_with?("/blog/tag") }
.reject { |href| href.match?("static/") }
- .reject { |href| href.match?(Regexp.union("privacy-policy", "events", "javascript")) }
+ .reject { |href| href.match?(Regexp.union("privacy-policy", "events", "javascript", "browse")) }
.select { |href| href.start_with?(Regexp.union("/", /\w+/)) }
.uniq
.each do |href|
diff --git a/spec/javascript/controllers/navigation_controller_spec.js b/spec/javascript/controllers/navigation_controller_spec.js
index 697eaa0140..a9e0a983e2 100644
--- a/spec/javascript/controllers/navigation_controller_spec.js
+++ b/spec/javascript/controllers/navigation_controller_spec.js
@@ -4,67 +4,369 @@ import NavigationController from 'navigation_controller.js';
describe('NavigationController', () => {
describe('opening and closing the nav menu', () => {
document.body.innerHTML = `
-
-
-
+
+
+
+`;
const application = Application.start();
application.register('navigation', NavigationController);
it('toggles the visibility of the navigation area when menu button clicked', () => {
- expect(document.querySelector('nav').classList).toContain(
- 'hidden-mobile'
- );
+ const nav = document.querySelector('nav');
+ const button = document.querySelector('button');
+
+ expect(nav.classList).toContain('hidden-mobile');
- document.querySelector('button').click();
+ button.click();
- expect(document.querySelector('nav').classList).not.toContain(
- 'hidden-mobile'
- );
+ expect(nav.classList).not.toContain('hidden-mobile');
- document.querySelector('button').click();
+ button.click();
- expect(document.querySelector('nav').classList).toContain(
- 'hidden-mobile'
- );
+ expect(nav.classList).toContain('hidden-mobile');
});
- it('toggles the menu visability when a menu item is clicked', () => {
- const menu = document.querySelector('ol.primary > li.menu > a');
- menu.click();
+ it('toggles the dropdown menu when a primary menu item is clicked', () => {
+ const primaryLink = document.querySelector('#is-teaching-right-for-me-mobile > a');
+ const icon = document.querySelector('#is-teaching-right-for-me-mobile > a > span.nav-icon');
+ const menu = document.getElementById('is-teaching-right-for-me-categories-desktop');
+
+ expect(icon.classList).toContain('nav-icon__contracted');
+ expect(menu.classList).toContain('hidden-menu');
+
+ primaryLink.click();
- expect(document.querySelector('li.menu').classList).toContain('down');
- expect(document.querySelector('ol.secondary').classList).not.toContain(
- 'hidden'
- );
+ expect(icon.classList).toContain('nav-icon__expanded');
+ expect(menu.classList).not.toContain('hidden-menu');
- menu.click();
+ primaryLink.click();
- expect(document.querySelector('li.menu').classList).toContain('up');
- expect(document.querySelector('ol.secondary').classList).toContain(
- 'hidden'
- );
+ expect(icon.classList).toContain('nav-icon__contracted');
+ expect(menu.classList).toContain('hidden-menu');
});
});
});
diff --git a/spec/models/pages/navigation_spec.rb b/spec/models/pages/navigation_spec.rb
index 9fd51625e8..8d5c401542 100644
--- a/spec/models/pages/navigation_spec.rb
+++ b/spec/models/pages/navigation_spec.rb
@@ -16,8 +16,8 @@
let(:page_five_subpages) do
{
"/page-five/part-1" => { title: "Page five: part 1", navigation: 5.1 },
- "/page-five/part-2" => { title: "Page five: part 2", navigation: 5.2 },
- "/page-five/part-3" => { title: "Page five: part 3", navigation: 5.3 },
+ "/page-five/part-2" => { title: "Page five: part 2", navigation: 5.2, subcategory: "category-1" },
+ "/page-five/part-3" => { title: "Page five: part 3", navigation: 5.3, subcategory: "category-2" },
}
end
@@ -194,6 +194,66 @@
end
end
+ describe "#children?" do
+ let(:nav) { Pages::Navigation.new(page_five_subpages.merge(page_six_subpages)) }
+
+ context "when there are child resources" do
+ subject { described_class.new(nav, "/page-five", { title: "Five", rank: 5, menu: true }) }
+
+ it "returns true" do
+ expect(subject.children?).to be true
+ end
+ end
+
+ context "when there are no child resources" do
+ subject { described_class.new(nav, "/page-five/part-3", { title: "Five", rank: 5, menu: true }) }
+
+ it "returns false" do
+ expect(subject.children?).to be false
+ end
+ end
+ end
+
+ describe "#subcategories" do
+ let(:nav) { Pages::Navigation.new(page_five_subpages.merge(page_six_subpages)) }
+
+ subject { described_class.new(nav, "/page-five", { title: "Five", rank: 5, menu: true }).subcategories }
+
+ it "returns a list of non-nil subcategories" do
+ expect(subject).to match_array(%w[category-1 category-2])
+ end
+ end
+
+ describe "#subcategories?" do
+ let(:nav) { Pages::Navigation.new(page_five_subpages.merge(page_six_subpages)) }
+
+ subject { described_class.new(nav, "/page-five", { title: "Five", rank: 5, menu: true }).subcategories? }
+
+ it "returns true" do
+ expect(subject).to be true
+ end
+ end
+
+ describe "#children_without_subcategory" do
+ let(:nav) { Pages::Navigation.new(page_five_subpages.merge(page_six_subpages)) }
+
+ subject { described_class.new(nav, "/page-five", { title: "Five", rank: 5, menu: true }).children_without_subcategory.map(&:title) }
+
+ it "returns a list of child pages without a subcategory" do
+ expect(subject).to match_array(["Page five: part 1"])
+ end
+ end
+
+ describe "#children_in_subcategory" do
+ let(:nav) { Pages::Navigation.new(page_five_subpages.merge(page_six_subpages)) }
+
+ subject { described_class.new(nav, "/page-five", { title: "Five", rank: 5, menu: true }).children_in_subcategory("category-2").map(&:title) }
+
+ it "returns a list of child pages with the specified subcategory" do
+ expect(subject).to match_array(["Page five: part 3"])
+ end
+ end
+
describe "#extract_title" do
[
OpenStruct.new(