Skip to content

Commit

Permalink
Service navigation (#556)
Browse files Browse the repository at this point in the history
The service navigation component sits beneath the header at the top of
the page and contains the service's name and it's primary navigation.

This implementation follows the upstream one very closely but has some
additional functionality:

* it accepts a `current_path` argument
* when navigation items match `current_path` exactly they are marked as
current
* for pages nested within a section, it accepts a `active_when` argument
* when navigation items begin with the active_when value they are marked
as 'active'
* if a regular expression is provided for `active_when`, any positive
match results in the node being marked 'active'
  • Loading branch information
peteryates authored Sep 5, 2024
2 parents 9300206 + 6a1ff03 commit d0de186
Show file tree
Hide file tree
Showing 15 changed files with 698 additions and 8 deletions.
14 changes: 11 additions & 3 deletions app/components/govuk_component/header_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ class GovukComponent::HeaderComponent < GovukComponent::Base
:menu_button_label,
:navigation_label,
:custom_navigation_classes,
:custom_container_classes
:custom_container_classes,
:full_width_border

def initialize(classes: [],
html_attributes: {},
Expand All @@ -19,7 +20,8 @@ def initialize(classes: [],
navigation_label: config.default_header_navigation_label,
service_name: config.default_header_service_name,
service_url: config.default_header_service_url,
container_classes: nil)
container_classes: nil,
full_width_border: false)

@homepage_url = homepage_url
@service_name = service_name
Expand All @@ -28,14 +30,20 @@ def initialize(classes: [],
@custom_navigation_classes = navigation_classes
@navigation_label = navigation_label
@custom_container_classes = container_classes
@full_width_border = full_width_border

super(classes:, html_attributes:)
end

private

def default_attributes
{ class: ["#{brand}-header"] }
{
class: class_names(
"#{brand}-header",
"#{brand}-header--full-width-border" => full_width_border
)
}
end

def navigation_html_attributes
Expand Down
92 changes: 92 additions & 0 deletions app/components/govuk_component/service_navigation_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
class GovukComponent::ServiceNavigationComponent < GovukComponent::Base
renders_one :start_slot
renders_one :end_slot

renders_one :service_name, "GovukComponent::ServiceNavigationComponent::ServiceNameComponent"
renders_many :navigation_items, ->(text:, href: nil, current_path: nil, active_when: nil, current: false, active: false, classes: [], html_attributes: {}) do
GovukComponent::ServiceNavigationComponent::NavigationItemComponent.new(
text:,
href:,
current_path: current_path || @current_path,
active_when:,
current:,
active:,
classes:,
html_attributes:
)
end

attr_reader :aria_label_text, :navigation_id

def initialize(service_name: nil, service_url: nil, navigation_items: [], current_path: nil, aria_label: "Service information", navigation_id: 'navigation', classes: [], html_attributes: {})
@service_name_text = service_name
@service_url = service_url
@current_path = current_path
@aria_label_text = aria_label
@navigation_id = navigation_id

if @service_name_text.present?
with_service_name(service_name: @service_name_text, service_url:)
end

navigation_items.each { |ni| with_navigation_item(current_path:, **ni) }

super(classes:, html_attributes:)
end

def call
outer_element do
tag.div(class: "#{brand}-width_container") do
safe_join(
[
start_slot,
tag.div(class: "#{brand}-service-navigation__container") do
safe_join([service_name, navigation].compact)
end,
end_slot
]
)
end
end
end

def navigation
return unless navigation_items?

tag.nav(aria: { label: "Menu" }, class: "#{brand}-service-navigation__wrapper") do
safe_join([menu_button, navigation_list])
end
end

def navigation_list
tag.ul(safe_join(navigation_items), id: navigation_id, class: "#{brand}-service-navigation__list")
end

private

def outer_element(&block)
if service_name?
tag.section(**aria_attributes, **html_attributes, &block)
else
tag.div(**html_attributes, &block)
end
end

def default_attributes
{ class: "#{brand}-service-navigation", data: { module: "#{brand}-service-navigation" } }
end

def aria_attributes
{ aria: { label: aria_label_text } }
end

def menu_button
tag.button(
"Menu",
type: 'button',
class: ["#{brand}-service-navigation__toggle", "#{brand}-js-service-navigation-toggle"],
aria: { controls: navigation_id },
hidden: true
)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
class GovukComponent::ServiceNavigationComponent::NavigationItemComponent < GovukComponent::Base
attr_reader :text, :href, :current_path, :active_when, :current, :active

def initialize(text:, href: nil, current_path: nil, active_when: nil, current: false, active: false, classes: [], html_attributes: {})
@current = current
@active = active
@text = text
@href = href

@current_path = current_path
@active_when = active_when

super(classes:, html_attributes:)
end

def call
tag.li(**html_attributes) do
if href.present?
wrap_link(link_to(text, href, class: "#{brand}-service-navigation__link", **aria_current))
else
tag.span(text, class: "#{brand}-service-navigation__text")
end
end
end

private

def wrap_link(link)
if current_or_active?
tag.strong(link, class: "#{brand}-service-navigation__active-fallback")
else
link
end
end

def current?
current || auto_current?
end

def active?
active || auto_active?
end

def current_or_active?
current? || active?
end

def auto_current?
return if current_path.blank?

current_path == href
end

def auto_active?
return if current_path.blank?

case active_when
when Regexp
active_when.match?(current_path)
when String
current_path.start_with?(active_when)
when Array
active_when.any? { |p| current_path.start_with?(p) }
else
false
end
end

def default_attributes
{
class: class_names(
"#{brand}-service-navigation__item",
"#{brand}-service-navigation__item--active" => current_or_active?
)
}
end

def aria_current
current = (current?) ? 'page' : true

{ aria: { current: } }
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class GovukComponent::ServiceNavigationComponent::ServiceNameComponent < GovukComponent::Base
attr_reader :service_name, :service_url

def initialize(service_name:, service_url: nil, classes: [], html_attributes: {})
@service_name = service_name
@service_url = service_url

super(classes:, html_attributes:)
end

def call
text = (service_url.present?) ? build_link : build_span

tag.span(text, **html_attributes)
end

private

def build_link
link_to(service_name, service_url, class: "#{brand}-service-navigation__link")
end

def build_span
tag.span(service_name, class: "#{brand}-service-navigation__text")
end

def default_attributes
{ class: "#{brand}-service-navigation__service-name" }
end
end
1 change: 1 addition & 0 deletions app/helpers/govuk_components_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module GovukComponentsHelper
govuk_pagination: 'GovukComponent::PaginationComponent',
govuk_panel: 'GovukComponent::PanelComponent',
govuk_phase_banner: 'GovukComponent::PhaseBannerComponent',
govuk_service_navigation: 'GovukComponent::ServiceNavigationComponent',
govuk_section_break: 'GovukComponent::SectionBreakComponent',
govuk_start_button: 'GovukComponent::StartButtonComponent',
govuk_summary_list: 'GovukComponent::SummaryListComponent',
Expand Down
66 changes: 66 additions & 0 deletions guide/content/components/service-navigation.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
title: Service navigation
---

p
| Service navigation helps users understand that they’re using your service
and lets them navigate around your service.

== render('/partials/example.*',
caption: "With only a service name",
code: service_navigation_with_only_service_name) do

markdown:
The Design System recommendation is [to display the service name in
the service navigation instead of in the header](https://design-system.service.gov.uk/components/service-navigation/#showing-your-service-name-only). On basic
services this means the service navigation can be rendered without any navigation links.

== render('/partials/example.*',
caption: "With navigation items",
code: service_navigation_with_a_current_page,
data: service_navigation_with_a_current_page_data) do

markdown:
Show navigation links to let users navigate to different parts of your
service and find useful links and tools.

When you are on the linked page you can use `current: true` to mark it. If
you're on a page within the section but not on the page itself, use
`active: true` instead.

== render('/partials/example.*',
caption: "Automatically detecting the current page",
code: service_navigation_with_navigation_items,
data: service_navigation_with_navigation_items_data) do

markdown:
Having to work out which page is current and adjust the parameters manually
would be complicated. If you pass in the `current_path`, when navigation
nodes that have a matching `href` are rendered, they'll be rendered as
the current page.

== render('/partials/example.*',
caption: "Automatic active page matching",
code: service_navigation_with_matching_subpages,
data: service_navigation_with_matching_subpages_data) do

markdown:
Often we have pages deeply nested within sections of the site and want
the navigation to show which section we're currently in. We can do this
using `active_when`.

When `active_when` is a string the component will check if the beginning of
the `current_path` matches it. For more complex scenarios a regular expression
can be used instead.

== render('/partials/example.*',
caption: "Manaully building the service navigation",
code: service_navigation_manual) do

markdown:
For extra customisation the service navigation can be assembled manually. We can
use the `start_slot` and `end_slot` to add custom HTML at the beginning and
end of the service navigation. This content needs its own custom styling or it will
be rendered above and below the other content.

== render('/partials/related-navigation.*', links: service_navigation_info)
7 changes: 7 additions & 0 deletions guide/content/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,10 @@ $govuk-page-width: 1100px;
color: tomato;
font-weight: bold;
}

// Manual service navigation
.app-service-navigation .govuk-width_container {
display: flex;
align-items: center;
gap: 1em;
}
Loading

0 comments on commit d0de186

Please sign in to comment.