Skip to content

Commit

Permalink
New link helpers (#419)
Browse files Browse the repository at this point in the history
This PR introduces a new set of link helpers that will ~be available as
an option but will eventually become default~ be the default. The legacy
100% Rails-compatible helpers will still be available for the time being
by including the helper module as described below.

The current helpers are closely tied to Rails' implementation which
after 18 years is showing its age. Because it pre-dates keyword
arguments and can take many formats of link, the code is complex and
difficult to extend.

The new implementation:

* is clean, all of the logic is handled by Rails'
[class_names](https://www.rubydoc.info/docs/rails/ActionView%2FHelpers%2FTagHelper:class_names)
helper
* does minimal argument juggling
* supports [opening links in new
tabs](https://design-system.service.gov.uk/styles/typography/#opening-links-in-a-new-tab)
* **does not support** the old-style verbose non-resource-orientated
`govuk_link_to("Show profile", controller: "profiles", action: "show",
id: @Profile)` format. I haven't seen much evidence of this style in use
and Rails' documentation [advises against using
it](https://api.rubyonrails.org/v7.0/classes/ActionView/Helpers/UrlHelper.html#method-i-link_to)

## Multiple versions of link helpers

For a while there will be two versions of the link helpers:

1. the new ones `GovukLinkHelper`
2. the old ones `GovukRailsCompatibileLinkHelper`

I originally planned to make choosing between them a config option but
due to the order the gem is required and then the intializers run, it's
a pain. Instead, the new ones will automatically be included and if the
old ones are wanted they can be manually included in
`ApplicationHelper`:

```ruby
module ApplicationHelper
  include GovukRailsCompatibileLinkHelper
end
```

This will make the old method variants override the new ones.

I will begin adding some warnings to the old ones before they are
dropped.

## Final things to do

* [ ] add some tests ensuring additional arguments (both `options` and
`html_options`) can still be passed through to the Rails helpers
* [ ] `visually_hidden_prefix` and `visually_hidden_suffix` keyword
arguments?
  • Loading branch information
peteryates authored Nov 26, 2023
2 parents 672bae0 + ceba1d0 commit aed6a5e
Show file tree
Hide file tree
Showing 8 changed files with 871 additions and 315 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def call
private

def default_attributes
link_classes = govuk_link_classes.append(classes).flatten
link_classes = safe_join([govuk_link_classes, classes], " ")

{ class: link_classes }
end
Expand Down
177 changes: 72 additions & 105 deletions app/helpers/govuk_link_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,156 +3,123 @@
module GovukLinkHelper
using HTMLAttributesUtils

def govuk_link_classes(*styles, default_class: "#{brand}-link")
if (invalid_styles = (styles - link_styles.keys)) && invalid_styles.any?
fail(ArgumentError, "invalid styles #{invalid_styles.to_sentence}. Valid styles are #{link_styles.keys.to_sentence}")
end

[default_class] + link_styles.values_at(*styles).compact
end

def govuk_button_classes(*styles, default_class: "#{brand}-button")
if (invalid_styles = (styles - button_styles.keys)) && invalid_styles.any?
fail(ArgumentError, "invalid styles #{invalid_styles.to_sentence}. Valid styles are #{button_styles.keys.to_sentence}")
end

[default_class] + button_styles.values_at(*styles).compact
end

def govuk_link_to(name = nil, options = nil, extra_options = {}, &block)
extra_options = options if block_given?
html_options = build_html_options(extra_options)
def govuk_link_to(name, href = nil, new_tab: false, inverse: false, muted: false, no_underline: false, no_visited_state: false, text_colour: false, **kwargs, &block)
link_args = extract_link_args(new_tab: new_tab, inverse: inverse, muted: muted, no_underline: no_underline, no_visited_state: no_visited_state, text_colour: text_colour, **kwargs)

if block_given?
link_to(name, html_options, &block)
link_to(block.call, href, **link_args)
else
link_to(name, options, html_options)
link_to(name, href, **link_args)
end
end

def govuk_mail_to(email_address, name = nil, extra_options = {}, &block)
extra_options = name if block_given?
html_options = build_html_options(extra_options)
def govuk_mail_to(email_address, name = nil, new_tab: false, inverse: false, muted: false, no_underline: false, no_visited_state: false, text_colour: false, **kwargs, &block)
link_args = extract_link_args(new_tab: new_tab, inverse: inverse, muted: muted, no_underline: no_underline, no_visited_state: no_visited_state, text_colour: text_colour, **kwargs)

if block_given?
mail_to(email_address, html_options, &block)
mail_to(email_address, block.call, **link_args)
else
mail_to(email_address, name, html_options)
mail_to(email_address, name, **link_args)
end
end

def govuk_button_to(name = nil, options = nil, extra_options = {}, &block)
extra_options = options if block_given?
html_options = {
data: { module: "govuk-button" }
}

if extra_options && extra_options[:prevent_double_click]
html_options[:data]["prevent-double-click"] = "true"
extra_options = extra_options.except(:prevent_double_click)
end

html_options.merge! build_html_options(extra_options, style: :button)
def govuk_button_to(name, href = nil, disabled: false, inverse: false, secondary: false, warning: false, **kwargs, &block)
button_args = extract_button_args(new_tab: false, disabled: disabled, inverse: inverse, secondary: secondary, warning: warning, **kwargs)

if block_given?
button_to(options, html_options, &block)
button_to(block.call, href, **button_args)
else
button_to(name, options, html_options)
button_to(name, href, **button_args)
end
end

def govuk_button_link_to(name = nil, options = nil, extra_options = {}, &block)
extra_options = options if block_given?
html_options = {
data: { module: "#{brand}-button" },
draggable: 'false',
role: 'button',
}.merge build_html_options(extra_options, style: :button)
def govuk_button_link_to(name, href = nil, new_tab: false, disabled: false, inverse: false, secondary: false, warning: false, **kwargs, &block)
button_args = extract_button_args(new_tab: new_tab, disabled: disabled, inverse: inverse, secondary: secondary, warning: warning, **kwargs)

if block_given?
link_to(name, html_options, &block)
link_to(block.call, href, **button_args)
else
link_to(name, options, html_options)
link_to(name, href, **button_args)
end
end

def govuk_breadcrumb_link_to(name = nil, options = nil, extra_options = {}, &block)
extra_options = options if block_given?
html_options = build_html_options(extra_options, style: :breadcrumb)
def govuk_breadcrumb_link_to(name, href = nil, **kwargs, &block)
link_args = { class: "#{brand}-breadcrumbs--link" }.deep_merge_html_attributes(kwargs)

if block_given?
link_to(name, html_options, &block)
link_to(block.call, href, **link_args)
else
link_to(name, options, html_options)
link_to(name, href, **link_args)
end
end

private
def govuk_link_classes(inverse: false, muted: false, no_underline: false, no_visited_state: false, text_colour: false)
if [text_colour, inverse, muted].count(true) > 1
fail("links can be only be one of text_colour, inverse or muted")
end

def brand
Govuk::Components.brand
class_names(
"#{brand}-link",
"#{brand}-link--inverse" => inverse,
"#{brand}-link--muted" => muted,
"#{brand}-link--no-underline" => no_underline,
"#{brand}-link--no-visited-state" => no_visited_state,
"#{brand}-link--text-colour" => text_colour,
)
end

def link_styles
{
inverse: "#{brand}-link--inverse",
muted: "#{brand}-link--muted",
no_underline: "#{brand}-link--no-underline",
no_visited_state: "#{brand}-link--no-visited-state",
text_colour: "#{brand}-link--text-colour",
}
end
def govuk_button_classes(disabled: false, inverse: false, secondary: false, warning: false)
if [inverse, secondary, warning].count(true) > 1
fail("buttons can only be one of inverse, secondary or warning")
end

def button_styles
{
disabled: "#{brand}-button--disabled",
secondary: "#{brand}-button--secondary",
warning: "#{brand}-button--warning",
inverse: "#{brand}-button--inverse",
}
class_names(
"#{brand}-button",
"#{brand}-button--disabled" => disabled,
"#{brand}-button--inverse" => inverse,
"#{brand}-button--secondary" => secondary,
"#{brand}-button--warning" => warning,
)
end

def build_html_options(provided_options, style: :link)
element_styles = { link: link_styles, button: button_styles }.fetch(style, {})

# we need to take a couple of extra steps here because we don't want the style
# params (inverse, muted, etc) to end up as extra attributes on the link.

remaining_options = remove_styles_from_provided_options(element_styles, provided_options)

style_classes = build_style_classes(style, extract_styles_from_provided_options(element_styles, provided_options))
private

combine_attributes(remaining_options, class_name: style_classes)
def new_tab_args(new_tab)
new_tab ? { target: "_blank", rel: "noreferrer noopener" } : {}
end

def build_style_classes(style, provided_options)
keys = *provided_options&.keys

case style
when :link then govuk_link_classes(*keys)
when :button then govuk_button_classes(*keys)
when :breadcrumb then "#{brand}-breadcrumbs__link"
end
def button_attributes(disabled)
disabled ? { disabled: true, aria: { disabled: true } } : {}
end

def combine_attributes(attributes, class_name:)
attributes ||= {}

attributes.with_indifferent_access.tap do |attrs|
attrs[:class] = Array.wrap(attrs[:class]).prepend(class_name).flatten.join(" ")
end
def extract_link_args(new_tab: false, inverse: false, muted: false, no_underline: false, no_visited_state: false, text_colour: false, **kwargs)
{
class: govuk_link_classes(
inverse: inverse,
muted: muted,
no_underline: no_underline,
no_visited_state: no_visited_state,
text_colour: text_colour
),
**new_tab_args(new_tab)
}.deep_merge_html_attributes(kwargs)
end

def extract_styles_from_provided_options(styles, provided_options)
return {} if provided_options.blank?

provided_options.slice(*styles.keys)
def extract_button_args(new_tab: false, disabled: false, inverse: false, secondary: false, warning: false, **kwargs)
{
class: govuk_button_classes(
disabled: disabled,
inverse: inverse,
secondary: secondary,
warning: warning
),
**button_attributes(disabled),
**new_tab_args(new_tab)
}.deep_merge_html_attributes(kwargs)
end

def remove_styles_from_provided_options(styles, provided_options)
return {} if provided_options.blank?

provided_options&.except(*styles.keys)
def brand
Govuk::Components.brand
end
end

Expand Down
157 changes: 157 additions & 0 deletions app/helpers/govuk_rails_compatibile_link_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
require "html_attributes_utils"

module GovukRailsCompatibileLinkHelper
using HTMLAttributesUtils

def govuk_link_classes(*styles, default_class: "#{brand}-link")
if (invalid_styles = (styles - link_styles.keys)) && invalid_styles.any?
fail(ArgumentError, "invalid styles #{invalid_styles.to_sentence}. Valid styles are #{link_styles.keys.to_sentence}")
end

[default_class] + link_styles.values_at(*styles).compact
end

def govuk_button_classes(*styles, default_class: "#{brand}-button")
if (invalid_styles = (styles - button_styles.keys)) && invalid_styles.any?
fail(ArgumentError, "invalid styles #{invalid_styles.to_sentence}. Valid styles are #{button_styles.keys.to_sentence}")
end

[default_class] + button_styles.values_at(*styles).compact
end

def govuk_link_to(name = nil, options = nil, extra_options = {}, &block)
extra_options = options if block_given?
html_options = build_html_options(extra_options)

if block_given?
link_to(name, html_options, &block)
else
link_to(name, options, html_options)
end
end

def govuk_mail_to(email_address, name = nil, extra_options = {}, &block)
extra_options = name if block_given?
html_options = build_html_options(extra_options)

if block_given?
mail_to(email_address, html_options, &block)
else
mail_to(email_address, name, html_options)
end
end

def govuk_button_to(name = nil, options = nil, extra_options = {}, &block)
extra_options = options if block_given?
html_options = {
data: { module: "govuk-button" }
}

if extra_options && extra_options[:prevent_double_click]
html_options[:data]["prevent-double-click"] = "true"
extra_options = extra_options.except(:prevent_double_click)
end

html_options.merge! build_html_options(extra_options, style: :button)

if block_given?
button_to(options, html_options, &block)
else
button_to(name, options, html_options)
end
end

def govuk_button_link_to(name = nil, options = nil, extra_options = {}, &block)
extra_options = options if block_given?
html_options = {
data: { module: "#{brand}-button" },
draggable: 'false',
role: 'button',
}.merge build_html_options(extra_options, style: :button)

if block_given?
link_to(name, html_options, &block)
else
link_to(name, options, html_options)
end
end

def govuk_breadcrumb_link_to(name = nil, options = nil, extra_options = {}, &block)
extra_options = options if block_given?
html_options = build_html_options(extra_options, style: :breadcrumb)

if block_given?
link_to(name, html_options, &block)
else
link_to(name, options, html_options)
end
end

private

def brand
Govuk::Components.brand
end

def link_styles
{
inverse: "#{brand}-link--inverse",
muted: "#{brand}-link--muted",
no_underline: "#{brand}-link--no-underline",
no_visited_state: "#{brand}-link--no-visited-state",
text_colour: "#{brand}-link--text-colour",
}
end

def button_styles
{
disabled: "#{brand}-button--disabled",
secondary: "#{brand}-button--secondary",
warning: "#{brand}-button--warning",
inverse: "#{brand}-button--inverse",
}
end

def build_html_options(provided_options, style: :link)
element_styles = { link: link_styles, button: button_styles }.fetch(style, {})

# we need to take a couple of extra steps here because we don't want the style
# params (inverse, muted, etc) to end up as extra attributes on the link.

remaining_options = remove_styles_from_provided_options(element_styles, provided_options)

style_classes = build_style_classes(style, extract_styles_from_provided_options(element_styles, provided_options))

combine_attributes(remaining_options, class_name: style_classes)
end

def build_style_classes(style, provided_options)
keys = *provided_options&.keys

case style
when :link then govuk_link_classes(*keys)
when :button then govuk_button_classes(*keys)
when :breadcrumb then "#{brand}-breadcrumbs__link"
end
end

def combine_attributes(attributes, class_name:)
attributes ||= {}

attributes.with_indifferent_access.tap do |attrs|
attrs[:class] = Array.wrap(attrs[:class]).prepend(class_name).flatten.join(" ")
end
end

def extract_styles_from_provided_options(styles, provided_options)
return {} if provided_options.blank?

provided_options.slice(*styles.keys)
end

def remove_styles_from_provided_options(styles, provided_options)
return {} if provided_options.blank?

provided_options&.except(*styles.keys)
end
end
Loading

0 comments on commit aed6a5e

Please sign in to comment.