diff --git a/lib/govuk_design_system_formbuilder.rb b/lib/govuk_design_system_formbuilder.rb index f9c39e90..f78fe392 100644 --- a/lib/govuk_design_system_formbuilder.rb +++ b/lib/govuk_design_system_formbuilder.rb @@ -102,7 +102,12 @@ module GOVUKDesignSystemFormBuilder default_collection_radio_buttons_include_hidden: true, default_collection_radio_buttons_auto_bold_labels: true, default_submit_validate: false, - + default_show_password_text: "Show", + default_hide_password_text: "Hide", + default_show_password_aria_label_text: "Show password", + default_hide_password_aria_label_text: "Hide password", + default_password_shown_announcement_text: "Your password is visible", + default_password_hidden_announcement_text: "Your password is hidden", localisation_schema_fallback: %i(helpers __context__), localisation_schema_label: nil, localisation_schema_hint: nil, diff --git a/lib/govuk_design_system_formbuilder/builder.rb b/lib/govuk_design_system_formbuilder/builder.rb index c9160e03..cd96e833 100644 --- a/lib/govuk_design_system_formbuilder/builder.rb +++ b/lib/govuk_design_system_formbuilder/builder.rb @@ -269,6 +269,70 @@ def govuk_number_field(attribute_name, hint: {}, label: {}, caption: {}, width: Elements::Inputs::Number.new(self, object_name, attribute_name, hint:, label:, caption:, width:, extra_letter_spacing:, form_group:, prefix_text:, suffix_text:, **kwargs, &block).html end + # Generates a password input + # + # @param attribute_name [Symbol] The name of the attribute + # @param hint [Hash,Proc] The content of the hint. No hint will be added if 'text' is left +nil+. When a +Proc+ is + # supplied the hint will be wrapped in a +div+ instead of a +span+ + # @option hint text [String] the hint text + # @option hint kwargs [Hash] additional arguments are applied as attributes to the hint + # @param label [Hash,Proc] configures or sets the associated label content + # @option label text [String] the label text + # @option label size [String] the size of the label font, can be +xl+, +l+, +m+, +s+ or nil + # @option label tag [Symbol,String] the label's wrapper tag, intended to allow labels to act as page headings + # @option label hidden [Boolean] control the visability of the label. Hidden labels will stil be read by screenreaders + # @option label kwargs [Hash] additional arguments are applied as attributes on the +label+ element + # @param caption [Hash] configures or sets the caption content which is inserted above the label + # @option caption text [String] the caption text + # @option caption size [String] the size of the caption, can be +xl+, +l+ or +m+. Defaults to +m+ + # @option caption kwargs [Hash] additional arguments are applied as attributes on the caption +span+ element + # @option kwargs [Hash] kwargs additional arguments are applied as attributes to the +input+ element + # @param form_group [Hash] configures the form group + # @option form_group kwargs [Hash] additional attributes added to the form group + # @param show_password_text [String] button text when the password is hidden. Defaults to "Show" + # @param hide_password_text [String] button text when the password is shown. Defaults to "Hide" + # @param show_password_aria_label_text [String] button text exposed to assistive technologies, like screen readers, when the password is hidden. Defaults to "Show password" + # @param hide_password_aria_label_text [String] button text exposed to assistive technologies, like screen readers, when the password is visible. Defaults to "Hide password" + # @param password_shown_announcement_text [String] Announcement made to screen reader users when their password has become visible in plain text. Defaults to "Your password is visible" + # @param password_hidden_announcement_text [String] Announcement made to screen reader users when their password has been obscured and is not visible. Defaults to "Your password is hidden" + # + # @example A password field + # = f.govuk_password_field :password + # + def govuk_password_field( + attribute_name, + hint: {}, + label: {}, + caption: {}, + form_group: {}, + show_password_text: config.default_show_password_text, + hide_password_text: config.default_hide_password_text, + show_password_aria_label_text: config.default_show_password_aria_label_text, + hide_password_aria_label_text: config.default_hide_password_aria_label_text, + password_shown_announcement_text: config.default_password_shown_announcement_text, + password_hidden_announcement_text: config.default_password_hidden_announcement_text, + **kwargs, + &block + ) + Elements::Password.new( + self, + object_name, + attribute_name, + hint:, + label:, + caption:, + form_group:, + show_password_text:, + hide_password_text:, + show_password_aria_label_text:, + hide_password_aria_label_text:, + password_shown_announcement_text:, + password_hidden_announcement_text:, + **kwargs, + &block + ).html + end + # Generates a +textarea+ element with a label, optional hint. Also offers # the ability to add the GOV.UK character and word counting components # automatically diff --git a/lib/govuk_design_system_formbuilder/elements/password.rb b/lib/govuk_design_system_formbuilder/elements/password.rb new file mode 100644 index 00000000..8476080d --- /dev/null +++ b/lib/govuk_design_system_formbuilder/elements/password.rb @@ -0,0 +1,106 @@ +module GOVUKDesignSystemFormBuilder + module Elements + class Password < Base + using PrefixableArray + + include Traits::Error + include Traits::Hint + include Traits::Label + include Traits::HTMLAttributes + include Traits::HTMLClasses + + I18nAttr = Struct.new(:key, :text, :default) + + def initialize(builder, object_name, attribute_name, label:, caption:, hint:, form_group:, show_password_text:, hide_password_text:, show_password_aria_label_text:, hide_password_aria_label_text:, password_hidden_announcement_text:, password_shown_announcement_text:, **kwargs, &block) + super(builder, object_name, attribute_name, &block) + + @label = label + @caption = caption + @hint = hint + @form_group = form_group + @html_attributes = kwargs + + @show_password_text = show_password_text + @hide_password_text = hide_password_text + + @show_password_aria_label_text = show_password_aria_label_text + @hide_password_aria_label_text = hide_password_aria_label_text + + @password_shown_announcement_text = password_shown_announcement_text + @password_hidden_announcement_text = password_hidden_announcement_text + end + + def html + Containers::FormGroup.new(*bound, **form_group_options).html do + safe_join([label_element, hint_element, error_element, password_input_and_button]) + end + end + + private + + def password_input_and_button + tag.div(class: wrapper_classes) do + safe_join([password_input, button]) + end + end + + def options + { + id: field_id(link_errors: true), + class: classes, + spellcheck: "false", + autocomplete: "current-password", + autocapitalize: "none", + aria: { describedby: combine_references(hint_id, error_id) } + } + end + + def form_group_options + { + **@form_group, + **i18n_data, + class: %(#{brand}-password-input), + data: { module: %(#{brand}-password-input) }, + } + end + + def password_input + @builder.password_field(@attribute_name, attributes(@html_attributes)) + end + + def classes + build_classes('input', 'password-input__input', 'js-password-input-input', %(password-input--error) => has_errors?).prefix(brand) + end + + def wrapper_classes + %w(input__wrapper password-input__wrapper).prefix(brand) + end + + def button + tag.button(@show_password_text, **button_options) + end + + def button_options + { + data: { module: %(#{brand}-button) }, + aria: { label: "Show password", controls: field_id(link_errors: true) }, + type: 'button', + class: %w(button button--secondary password-input__toggle js-password-input-toggle).prefix(brand) + } + end + + def i18n_data + [ + I18nAttr.new("data-i18n.show-password", @show_password_text, config.default_show_password_text), + I18nAttr.new("data-i18n.hide-password", @hide_password_text, config.default_hide_password_text), + I18nAttr.new("data-i18n.show-password-aria-label", @show_password_aria_label_text, config.default_show_password_aria_label_text), + I18nAttr.new("data-i18n.hide-password-aria-label", @hide_password_aria_label_text, config.default_hide_password_aria_label_text), + I18nAttr.new("data-i18n.password-shown-announcement", @password_shown_announcement_text, config.default_password_shown_announcement_text), + I18nAttr.new("data-i18n.password-hidden-announcement", @password_hidden_announcement_text, config.default_password_hidden_announcement_text), + ].each_with_object({}) do |attr, h| + h[attr.key] = attr.text unless attr.text == attr.default + end + end + end + end +end diff --git a/spec/govuk_design_system_formbuilder/builder/date_spec.rb b/spec/govuk_design_system_formbuilder/builder/date_spec.rb index 5140770d..d1447823 100644 --- a/spec/govuk_design_system_formbuilder/builder/date_spec.rb +++ b/spec/govuk_design_system_formbuilder/builder/date_spec.rb @@ -1,7 +1,7 @@ describe GOVUKDesignSystemFormBuilder::FormBuilder do include_context 'setup builder' - describe '#date_input_group' do + describe '#govuk_date_field' do let(:method) { :govuk_date_field } let(:attribute) { :born_on } diff --git a/spec/govuk_design_system_formbuilder/builder/error_summary_spec.rb b/spec/govuk_design_system_formbuilder/builder/error_summary_spec.rb index 3e53bde4..8e9c08b9 100644 --- a/spec/govuk_design_system_formbuilder/builder/error_summary_spec.rb +++ b/spec/govuk_design_system_formbuilder/builder/error_summary_spec.rb @@ -305,7 +305,7 @@ specify "errors are displayed in the order they're defined in the model" do expect(object.name).to be_present - expect(actual_order).to eql(%w(favourite_colour projects cv)) + expect(actual_order).to eql(%w(favourite_colour projects cv password)) end end diff --git a/spec/govuk_design_system_formbuilder/builder/password_spec.rb b/spec/govuk_design_system_formbuilder/builder/password_spec.rb new file mode 100644 index 00000000..6f0e782f --- /dev/null +++ b/spec/govuk_design_system_formbuilder/builder/password_spec.rb @@ -0,0 +1,213 @@ +describe GOVUKDesignSystemFormBuilder::FormBuilder do + include_context 'setup builder' + + describe '#govuk_password_field' do + let(:method) { :govuk_password_field } + let(:attribute) { :password } + let(:label_text) { 'Enter your password' } + let(:hint_text) { 'Keep it safe' } + let(:args) { [method, attribute] } + let(:kwargs) { {} } + let(:field_type) { 'input' } + subject { builder.send(*args, **kwargs) } + + specify 'renders a form group containing a wrapper around an input and button' do + expect(subject).to have_tag('div', with: { class: 'govuk-form-group' }) do + with_tag('div', with: { class: %w(govuk-input__wrapper govuk-password-input__wrapper) }) do + with_tag('input', with: { type: 'password' }) + with_tag('button') + end + end + end + + specify 'the form group has the password data module' do + expect(subject).to have_tag('div', with: { class: 'govuk-form-group', "data-module" => "govuk-password-input" }) + end + + specify 'the password input has the right classes' do + expect(subject).to have_tag('input', with: { class: %w(govuk-input govuk-password-input__input govuk-js-password-input-input) }) + end + + specify 'the password input has the right attributes' do + expect(subject).to have_tag( + 'input', + with: { + id: "person-password-field", + spellcheck: false, + autocomplete: "current-password", + autocapitalize: "none" + } + ) + end + + specify 'the button has the right classes' do + expected_classes = %w(govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle) + + expect(subject).to have_tag('button', with: { class: expected_classes }) + end + + specify 'the button has the right attributes' do + expected_attributes = { + "data-module" => "govuk-button", + "aria-label" => "Show password", + "aria-controls" => "person-password-field", + } + + expect(subject).to have_tag('button', with: expected_attributes) + end + + specify 'the button has the right classes' do + expect(subject).to have_tag( + 'button', + with: { + class: %w(govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle) + } + ) + end + + describe 'customising the show and hide text' do + let(:xpath_password_module_selector) { %(./div[@data-module="govuk-password-input"]) } + let(:i18n_data_attributes) do + %w( + data-i18n.hide-password + data-i18n.show-password-aria-label + data-i18n.hide-password-aria-label + data-i18n.password-shown-announcement + data-i18n.password-hidden-announcement + ) + end + + context 'when the show button text is customised' do + let(:show_password_key) { "data-i18n.show-password" } + let(:custom_show_password_text) { "Reveal" } + let(:kwargs) { { show_password_text: custom_show_password_text } } + + specify "the show button has the provided text" do + expect(subject).to have_tag("button", text: custom_show_password_text) + end + + specify 'the show button has the the corresponding i18n data attribute set' do + show_password_attr = parsed_subject.at_xpath(xpath_password_module_selector).attributes.fetch(show_password_key).value + + expect(show_password_attr).to eql(custom_show_password_text) + end + + specify 'no other i18n data attributes are set' do + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).to include(show_password_key) + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).not_to include(*i18n_data_attributes.excluding(show_password_key)) + end + end + + context 'when the hide button text is customised' do + let(:hide_password_key) { "data-i18n.hide-password" } + let(:custom_hide_password_text) { "Conceal" } + let(:kwargs) { { hide_password_text: custom_hide_password_text } } + + specify 'the hide button has the the corresponding i18n data attribute set' do + hide_password_attr = parsed_subject.at_xpath(xpath_password_module_selector).attributes.fetch(hide_password_key).value + + expect(hide_password_attr).to eql(custom_hide_password_text) + end + + specify 'no other i18n data attributes are set' do + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).to include(hide_password_key) + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).not_to include(*i18n_data_attributes.excluding(hide_password_key)) + end + end + + context 'when the hide password aria label is customised' do + let(:hide_password_key) { "data-i18n.show-password-aria-label" } + let(:custom_show_password_aria_label_text) { "Secrete the password" } + let(:kwargs) { { show_password_aria_label_text: custom_show_password_aria_label_text } } + + specify 'the hide button has the the corresponding i18n data attribute set' do + hide_password_attr = parsed_subject.at_xpath(xpath_password_module_selector).attributes.fetch(hide_password_key).value + + expect(hide_password_attr).to eql(custom_show_password_aria_label_text) + end + + specify 'no other i18n data attributes are set' do + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).to include(hide_password_key) + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).not_to include(*i18n_data_attributes.excluding(hide_password_key)) + end + end + + context 'when the hide password aria label is customised' do + let(:hide_password_aria_label_key) { "data-i18n.hide-password-aria-label" } + let(:custom_hide_password_aria_label_text) { "Obscure the password" } + let(:kwargs) { { hide_password_aria_label_text: custom_hide_password_aria_label_text } } + + specify 'the hide button has the the corresponding i18n data attribute set' do + hide_password_attr = parsed_subject.at_xpath(xpath_password_module_selector).attributes.fetch(hide_password_aria_label_key).value + + expect(hide_password_attr).to eql(custom_hide_password_aria_label_text) + end + + specify 'no other i18n data attributes are set' do + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).to include(hide_password_aria_label_key) + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).not_to include(*i18n_data_attributes.excluding(hide_password_aria_label_key)) + end + end + + context 'when the password shown announcement text is customised' do + let(:password_shown_announcement_text_key) { "data-i18n.password-shown-announcement" } + let(:custom_password_shown_announcement_text) { "The password has been revealed" } + let(:kwargs) { { password_shown_announcement_text: custom_password_shown_announcement_text } } + + specify 'the show button has the the corresponding i18n data attribute set' do + show_password_attr = parsed_subject.at_xpath(xpath_password_module_selector).attributes.fetch(password_shown_announcement_text_key).value + + expect(show_password_attr).to eql(custom_password_shown_announcement_text) + end + + specify 'no other i18n data attributes are set' do + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).to include(password_shown_announcement_text_key) + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).not_to include(*i18n_data_attributes.excluding(password_shown_announcement_text_key)) + end + end + + context 'when the password hidden announcement text is customised' do + let(:password_hidden_announcement_text_key) { "data-i18n.password-hidden-announcement" } + let(:custom_password_hidden_announcement_text) { "The password has been obscured" } + let(:kwargs) { { password_hidden_announcement_text: custom_password_hidden_announcement_text } } + + specify 'the hide button has the the corresponding i18n data attribute set' do + hide_password_attr = parsed_subject.at_xpath(xpath_password_module_selector).attributes.fetch(password_hidden_announcement_text_key).value + + expect(hide_password_attr).to eql(custom_password_hidden_announcement_text) + end + + specify 'no other i18n data attributes are set' do + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).to include(password_hidden_announcement_text_key) + expect(parsed_subject.at_xpath(xpath_password_module_selector).attributes.keys).not_to include(*i18n_data_attributes.excluding(password_hidden_announcement_text_key)) + end + end + end + + describe 'setting autocomplete' do + let(:kwargs) { { autocomplete: "cc-csc" } } + + specify 'sets the autocomplete value to the one provided' do + expect(subject).to have_tag("input", with: { name: "person[password]", autocomplete: "cc-csc" }) + end + end + + it_behaves_like 'a field that supports labels' + it_behaves_like 'a field that supports labels as procs' + it_behaves_like 'a field that supports captions on the label' + it_behaves_like 'a field that supports custom branding' + + it_behaves_like 'a field that supports hints' do + let(:aria_described_by_target) { 'input' } + end + + it_behaves_like 'a field that supports errors' do + let(:object) { Person.new(photo: 'me.tiff') } + let(:aria_described_by_target) { 'input' } + + let(:error_message) { /Password must be longer than 8 characters/ } + let(:error_class) { 'govuk-password-input--error' } + let(:error_identifier) { 'person-password-error' } + end + end +end diff --git a/spec/support/examples.rb b/spec/support/examples.rb index b6c60c22..515cbf93 100644 --- a/spec/support/examples.rb +++ b/spec/support/examples.rb @@ -25,6 +25,7 @@ class Being :stationery_choice, :hairstyle, :favourite_shape, + :password ) def initialize(_args = nil) @@ -47,6 +48,9 @@ class Person < Being validates :hairstyle, presence: { message: 'Describe your
hairstyle' }, on: :trust_error_messages + validates :password, + length: { minimum: 8, message: 'Password must be longer than 8 characters' } + def self.valid_example new( name: 'Milhouse van Houten',