diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a11cc3f62..f5baa11827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ For advice on how to use these release notes see [our guidance on staying up to ## Unreleased +### New features + +#### Use the Passwords input component to help users accessibly enter passwords + +The [Password input component](https://design-system.service.gov.uk/components/password-input/) allows users to toggle the visibility of passwords and enter their passwords in plain text if they choose to do so. + +This helps users use longer and more complex passwords without needing to remember what they've already typed. + +This change was introduced in [pull request #4442: Create password input component](https://github.com/alphagov/govuk-frontend/pull/4442). Thanks to [@andysellick](https://github.com/andysellick) for the original contribution. + ### Recommended changes #### Update the HTML for the character count diff --git a/packages/govuk-frontend-review/src/routes/full-page-examples.puppeteer.test.mjs b/packages/govuk-frontend-review/src/routes/full-page-examples.puppeteer.test.mjs index 997e2cf586..7a90ed4bd7 100644 --- a/packages/govuk-frontend-review/src/routes/full-page-examples.puppeteer.test.mjs +++ b/packages/govuk-frontend-review/src/routes/full-page-examples.puppeteer.test.mjs @@ -52,6 +52,10 @@ describe('Full page examples (with form submit)', () => { title: 'Passport details', path: '/full-page-examples/passport-details' }, + { + title: 'Sign in to a service', + path: '/full-page-examples/sign-in' + }, { title: 'Update your account details', path: '/full-page-examples/update-your-account-details' diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/index.mjs b/packages/govuk-frontend-review/src/views/full-page-examples/index.mjs index 0df907729a..d7e26f3502 100644 --- a/packages/govuk-frontend-review/src/views/full-page-examples/index.mjs +++ b/packages/govuk-frontend-review/src/views/full-page-examples/index.mjs @@ -8,6 +8,7 @@ export { default as haveYouChangedYourName } from './have-you-changed-your-name/ export { default as feedback } from './feedback/index.mjs' export { default as howDoYouWantToSignIn } from './how-do-you-want-to-sign-in/index.mjs' export { default as search } from './search/index.mjs' +export { default as signIn } from './sign-in/index.mjs' export { default as passportDetails } from './passport-details/index.mjs' export { default as updateYourAccountDetails } from './update-your-account-details/index.mjs' export { default as uploadYourPhoto } from './upload-your-photo/index.mjs' diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/confirm.njk b/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/confirm.njk new file mode 100644 index 0000000000..06a6a9a616 --- /dev/null +++ b/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/confirm.njk @@ -0,0 +1,16 @@ +{% extends "layouts/full-page-example.njk" %} + +{% from "govuk/components/panel/macro.njk" import govukPanel %} + +{% set pageTitle = "Logged in successfully" %} +{% block pageTitle %}{{ pageTitle }} - GOV.UK{% endblock %} + +{% block content %} +
+
+ {{ govukPanel({ + titleText: pageTitle + }) }} +
+
+{% endblock %} diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/index.mjs b/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/index.mjs new file mode 100644 index 0000000000..d86aeae31b --- /dev/null +++ b/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/index.mjs @@ -0,0 +1,39 @@ +import express from 'express' +import { body, matchedData, validationResult } from 'express-validator' + +import { formatValidationErrors } from '../../../utils.mjs' + +const router = express.Router() + +router.post( + '/sign-in', + + body('email') + .notEmpty() + .withMessage('Enter your email address') + .isEmail() + .withMessage( + 'Enter an email address in the correct format, like name@example.com' + ), + + body('password').notEmpty().withMessage('Enter your password'), + + (req, res) => { + const { example } = res.locals + + const viewPath = `./full-page-examples/${example.path}` + const errors = formatValidationErrors(validationResult(req)) + + if (!errors) { + return res.redirect(303, `./${example.path}/confirm`) + } + + res.render(`${viewPath}/index`, { + errors, + errorSummary: Object.values(errors), + values: matchedData(req, { onlyValidData: false }) // In production this should sanitized. + }) + } +) + +export default router diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/index.njk b/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/index.njk new file mode 100644 index 0000000000..0de48400fd --- /dev/null +++ b/packages/govuk-frontend-review/src/views/full-page-examples/sign-in/index.njk @@ -0,0 +1,90 @@ +--- +title: Sign in to a service +name: Sign in to a service +scenario: | + As part of an online service, you have to sign in before accessing it. + + Things to try: + + 1. Intentionally avoid answering the questions before continuing to the next page. +--- + +{% extends "layouts/full-page-example.njk" %} + +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/password-input/macro.njk" import govukPasswordInput %} +{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% set pageTitle = example.title %} +{% block pageTitle %}{{ "Error: " if errorSummary | length }}{{ pageTitle }} - GOV.UK{% endblock %} + +{% block beforeContent %} + {{ govukBackLink({ + text: "Back" + }) }} +{% endblock %} + +{% block content %} +
+
+ {% if errorSummary | length %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: errorSummary + }) }} + {% endif %} + +

{{ pageTitle }}

+ +

You'll first need to create an account if you haven't already. You can also reset your password if you can't remember it.

+ +
+ + {{ govukInput({ + label: { + text: "Email address", + classes: "govuk-label--m" + }, + type: "email", + id: "email", + name: "email", + value: values["email"], + errorMessage: errors["email"], + autocomplete: "email", + spellcheck: false + }) }} + + {{ govukPasswordInput({ + label: { + text: "Password", + classes: "govuk-label--m" + }, + id: "password", + name: "password", + value: values["password"], + errorMessage: errors["password"], + autocomplete: "current-password" + }) }} + + {{ govukCheckboxes({ + name: "remember-me", + classes: "govuk-checkboxes--small", + items: [ + { + value: "true", + text: "Keep me signed in on this device" + } + ] + }) }} + + {{ govukButton({ + text: "Sign in" + }) }} + +
+
+
+{% endblock %} diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/update-your-account-details/index.mjs b/packages/govuk-frontend-review/src/views/full-page-examples/update-your-account-details/index.mjs index 7668ac7e88..f321f9d38a 100644 --- a/packages/govuk-frontend-review/src/views/full-page-examples/update-your-account-details/index.mjs +++ b/packages/govuk-frontend-review/src/views/full-page-examples/update-your-account-details/index.mjs @@ -16,7 +16,7 @@ router.post( 'Enter an email address in the correct format, like name@example.com' ), - body('password').notEmpty().withMessage('Enter your password'), + body('password').notEmpty().withMessage('Enter a password'), (req, res) => { const { example } = res.locals diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/update-your-account-details/index.njk b/packages/govuk-frontend-review/src/views/full-page-examples/update-your-account-details/index.njk index cd8a000a92..498c11f875 100644 --- a/packages/govuk-frontend-review/src/views/full-page-examples/update-your-account-details/index.njk +++ b/packages/govuk-frontend-review/src/views/full-page-examples/update-your-account-details/index.njk @@ -13,6 +13,7 @@ scenario: | {% from "govuk/components/back-link/macro.njk" import govukBackLink %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/password-input/macro.njk" import govukPasswordInput %} {% from "govuk/components/button/macro.njk" import govukButton %} {% set pageTitle = example.title %} @@ -52,18 +53,16 @@ scenario: | spellcheck: false }) }} - {{ govukInput({ + {{ govukPasswordInput({ label: { text: "New password", classes: "govuk-label--m" }, - type: "password", id: "password", name: "password", value: values["password"], errorMessage: errors["password"], - autocomplete: "new-password", - spellcheck: false + autocomplete: "new-password" }) }} {{ govukButton({ diff --git a/packages/govuk-frontend/src/govuk-prototype-kit/govuk-prototype-kit.config.unit.test.mjs b/packages/govuk-frontend/src/govuk-prototype-kit/govuk-prototype-kit.config.unit.test.mjs index 77b5894c0c..96af4b48d8 100644 --- a/packages/govuk-frontend/src/govuk-prototype-kit/govuk-prototype-kit.config.unit.test.mjs +++ b/packages/govuk-frontend/src/govuk-prototype-kit/govuk-prototype-kit.config.unit.test.mjs @@ -136,6 +136,10 @@ describe('GOV.UK Prototype Kit config', () => { importFrom: 'govuk/components/panel/macro.njk', macroName: 'govukPanel' }, + { + importFrom: 'govuk/components/password-input/macro.njk', + macroName: 'govukPasswordInput' + }, { importFrom: 'govuk/components/phase-banner/macro.njk', macroName: 'govukPhaseBanner' diff --git a/packages/govuk-frontend/src/govuk/all.jsdom.test.mjs b/packages/govuk-frontend/src/govuk/all.jsdom.test.mjs index bdcf416dc7..475cfad395 100644 --- a/packages/govuk-frontend/src/govuk/all.jsdom.test.mjs +++ b/packages/govuk-frontend/src/govuk/all.jsdom.test.mjs @@ -14,6 +14,7 @@ jest.mock(`./components/error-summary/error-summary.mjs`) jest.mock(`./components/exit-this-page/exit-this-page.mjs`) jest.mock(`./components/header/header.mjs`) jest.mock(`./components/notification-banner/notification-banner.mjs`) +jest.mock(`./components/password-input/password-input.mjs`) jest.mock(`./components/radios/radios.mjs`) jest.mock(`./components/skip-link/skip-link.mjs`) jest.mock(`./components/tabs/tabs.mjs`) @@ -27,7 +28,8 @@ describe('initAll', () => { 'character-count', 'error-summary', 'exit-this-page', - 'notification-banner' + 'notification-banner', + 'password-input' ] afterEach(() => { diff --git a/packages/govuk-frontend/src/govuk/all.mjs b/packages/govuk-frontend/src/govuk/all.mjs index 86bfb13f74..47a42b672b 100644 --- a/packages/govuk-frontend/src/govuk/all.mjs +++ b/packages/govuk-frontend/src/govuk/all.mjs @@ -10,6 +10,7 @@ import { ErrorSummary } from './components/error-summary/error-summary.mjs' import { ExitThisPage } from './components/exit-this-page/exit-this-page.mjs' import { Header } from './components/header/header.mjs' import { NotificationBanner } from './components/notification-banner/notification-banner.mjs' +import { PasswordInput } from './components/password-input/password-input.mjs' import { Radios } from './components/radios/radios.mjs' import { SkipLink } from './components/skip-link/skip-link.mjs' import { Tabs } from './components/tabs/tabs.mjs' @@ -41,6 +42,7 @@ function initAll(config) { [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], + [PasswordInput, config.passwordInput], [Radios], [SkipLink], [Tabs] @@ -81,6 +83,7 @@ export { ExitThisPage, Header, NotificationBanner, + PasswordInput, Radios, SkipLink, Tabs @@ -96,6 +99,7 @@ export { * @property {ErrorSummaryConfig} [errorSummary] - Error Summary config * @property {ExitThisPageConfig} [exitThisPage] - Exit This Page config * @property {NotificationBannerConfig} [notificationBanner] - Notification Banner config + * @property {PasswordInputConfig} [passwordInput] - Password input config */ /** @@ -110,6 +114,7 @@ export { * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageConfig} ExitThisPageConfig * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageTranslations} ExitThisPageTranslations * @typedef {import('./components/notification-banner/notification-banner.mjs').NotificationBannerConfig} NotificationBannerConfig + * @typedef {import('./components/password-input/password-input.mjs').PasswordInputConfig} PasswordInputConfig */ /** diff --git a/packages/govuk-frontend/src/govuk/components/_all.scss b/packages/govuk-frontend/src/govuk/components/_all.scss index a11c30e7a1..ae246e4950 100644 --- a/packages/govuk-frontend/src/govuk/components/_all.scss +++ b/packages/govuk-frontend/src/govuk/components/_all.scss @@ -23,6 +23,7 @@ @import "notification-banner/index"; @import "pagination/index"; @import "panel/index"; +@import "password-input/index"; @import "phase-banner/index"; @import "radios/index"; @import "select/index"; diff --git a/packages/govuk-frontend/src/govuk/components/globals.puppeteer.test.js b/packages/govuk-frontend/src/govuk/components/globals.puppeteer.test.js index b30dd4fbbc..c5992d85ad 100644 --- a/packages/govuk-frontend/src/govuk/components/globals.puppeteer.test.js +++ b/packages/govuk-frontend/src/govuk/components/globals.puppeteer.test.js @@ -43,6 +43,7 @@ describe('GOV.UK Frontend', () => { 'ExitThisPage', 'Header', 'NotificationBanner', + 'PasswordInput', 'Radios', 'SkipLink', 'Tabs' diff --git a/packages/govuk-frontend/src/govuk/components/input/input.yaml b/packages/govuk-frontend/src/govuk/components/input/input.yaml index 277766431d..439bc6a333 100644 --- a/packages/govuk-frontend/src/govuk/components/input/input.yaml +++ b/packages/govuk-frontend/src/govuk/components/input/input.yaml @@ -139,6 +139,23 @@ params: type: boolean required: false description: Optional field to enable or disable the `spellcheck` attribute on the input. + - name: autocapitalize + type: string + required: false + description: Optional field to enable or disable autocapitalisation of user input. See [autocapitalization](https://html.spec.whatwg.org/multipage/interaction.html#autocapitalization) for a full list of values that can be used. + - name: inputWrapper + type: object + required: false + description: If any of `prefix`, `suffix`, `formGroup.beforeInput` or `formGroup.afterInput` have a value, a wrapping element is added around the input and inserted content. This object allows you to customise that wrapping element. + params: + - name: classes + type: string + required: false + description: Classes to add to the wrapping element. + - name: attributes + type: object + required: false + description: HTML attributes (for example data attributes) to add to the wrapping element. - name: attributes type: object required: false @@ -279,6 +296,14 @@ examples: name: spellcheck type: text spellcheck: false + - name: with autocapitalize turned off + options: + label: + text: Autocapitalize is turned off + id: input-with-autocapitalize-off + name: autocapitalize + type: text + autocapitalize: none - name: with prefix options: @@ -522,3 +547,18 @@ examples: html: kg attributes: data-attribute: value + - name: with customised input wrapper + hidden: true + options: + label: + text: Cost per item, in pounds + id: input-with-customised-input-wrapper + name: cost + inputWrapper: + classes: app-input-wrapper--custom-modifier + attributes: + data-attribute: value + prefix: + text: £ + suffix: + text: per item diff --git a/packages/govuk-frontend/src/govuk/components/input/template.njk b/packages/govuk-frontend/src/govuk/components/input/template.njk index ed00eee798..bb4666d4ac 100644 --- a/packages/govuk-frontend/src/govuk/components/input/template.njk +++ b/packages/govuk-frontend/src/govuk/components/input/template.njk @@ -21,6 +21,7 @@ {%- if params.autocomplete %} autocomplete="{{ params.autocomplete }}"{% endif %} {%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %} {%- if params.inputmode %} inputmode="{{ params.inputmode }}"{% endif %} + {%- if params.autocapitalize %} autocapitalize="{{ params.autocapitalize }}"{% endif %} {{- govukAttributes(params.attributes) }}> {%- endmacro -%} @@ -65,7 +66,8 @@ {% endif %} {%- if hasPrefix or hasSuffix or hasBeforeInput or hasAfterInput %} -
+
{% if hasBeforeInput %} {{- params.formGroup.beforeInput.html | safe | trim | indent(4, true) if params.formGroup.beforeInput.html else params.formGroup.beforeInput.text }} {% endif %} diff --git a/packages/govuk-frontend/src/govuk/components/input/template.test.js b/packages/govuk-frontend/src/govuk/components/input/template.test.js index abd3278085..013b983694 100644 --- a/packages/govuk-frontend/src/govuk/components/input/template.test.js +++ b/packages/govuk-frontend/src/govuk/components/input/template.test.js @@ -205,6 +205,22 @@ describe('Input', () => { }) }) + describe('when it has the autocapitalize attribute', () => { + it('renders without autocapitalize attribute by default', () => { + const $ = render('input', examples.default) + + const $component = $('.govuk-input') + expect($component.attr('autocapitalize')).toBeUndefined() + }) + + it('renders with autocapitalize attribute when set', () => { + const $ = render('input', examples['with autocapitalize turned off']) + + const $component = $('.govuk-input') + expect($component.attr('autocapitalize')).toBe('none') + }) + }) + describe('when it includes both a hint and an error message', () => { it('associates the input as described by both the hint and the error message', () => { const $ = render('input', examples['with error and hint']) @@ -439,4 +455,22 @@ describe('Input', () => { expect($prefixBeforeSuffix.length).toBeTruthy() }) }) + + describe('when it includes the input wrapper', () => { + it('renders the input wrapper with custom classes', () => { + const $ = render('input', examples['with customised input wrapper']) + + const $wrapper = $('.govuk-form-group > .govuk-input__wrapper') + expect( + $wrapper.hasClass('app-input-wrapper--custom-modifier') + ).toBeTruthy() + }) + + it('renders the input wrapper with custom attributes', () => { + const $ = render('input', examples['with customised input wrapper']) + + const $wrapper = $('.govuk-form-group > .govuk-input__wrapper') + expect($wrapper.attr('data-attribute')).toBe('value') + }) + }) }) diff --git a/packages/govuk-frontend/src/govuk/components/password-input/_index.scss b/packages/govuk-frontend/src/govuk/components/password-input/_index.scss new file mode 100644 index 0000000000..9bcaa69587 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/password-input/_index.scss @@ -0,0 +1,53 @@ +@import "../button/index"; +@import "../input/index"; + +@include govuk-exports("govuk/component/password-input") { + .govuk-password-input__wrapper { + // This element inherits styles from .govuk-input__wrapper, including: + // - being display: block with contents in a stacked column below the mobile breakpoint + // - being display: flex above the mobile breakpoint + + @include govuk-media-query($from: mobile) { + flex-direction: row; + + // The default of `stretch` makes the toggle button appear taller than the input, due to using + // box-shadow, which we don't particularly want in this situation + align-items: flex-start; + } + } + + .govuk-password-input__input { + // IE 11 and Microsoft Edge comes with its own password reveal function. We want to hide it, + // so that there aren't two controls presented to the user that do the same thing but aren't in + // sync with one another. This doesn't affect the function that allows Edge users to toggle + // password visibility by pressing Alt+F8, which cannot be programatically disabled. + &::-ms-reveal { + display: none; + } + } + + .govuk-password-input__toggle { + // Add margin to the top so that the button doesn't obscure the input's focus style + margin-top: govuk-spacing(1); + + // Remove default margin-bottom from button + margin-bottom: 0; + + // Hide the button by default, JS removes this attribute + &[hidden] { + display: none; + } + + @include govuk-media-query($from: mobile) { + // Buttons are normally 100% width on this breakpoint, but we don't want that in this case + width: auto; + flex-grow: 1; + flex-shrink: 0; + flex-basis: 5em; + + // Move the spacing from top to the left + margin-top: 0; + margin-left: govuk-spacing(1); + } + } +} diff --git a/packages/govuk-frontend/src/govuk/components/password-input/_password-input.scss b/packages/govuk-frontend/src/govuk/components/password-input/_password-input.scss new file mode 100644 index 0000000000..bfabb03440 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/password-input/_password-input.scss @@ -0,0 +1,2 @@ +@import "../../base"; +@import "./index"; diff --git a/packages/govuk-frontend/src/govuk/components/password-input/accessibility.puppeteer.test.mjs b/packages/govuk-frontend/src/govuk/components/password-input/accessibility.puppeteer.test.mjs new file mode 100644 index 0000000000..3eb7155568 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/password-input/accessibility.puppeteer.test.mjs @@ -0,0 +1,15 @@ +import { axe, render } from '@govuk-frontend/helpers/puppeteer' +import { getExamples } from '@govuk-frontend/lib/components' + +describe('/components/password-input', () => { + describe('component examples', () => { + it('passes accessibility tests', async () => { + const examples = await getExamples('password-input') + + for (const exampleName in examples) { + await render(page, 'password-input', examples[exampleName]) + await expect(axe(page)).resolves.toHaveNoViolations() + } + }, 120000) + }) +}) diff --git a/packages/govuk-frontend/src/govuk/components/password-input/macro.njk b/packages/govuk-frontend/src/govuk/components/password-input/macro.njk new file mode 100644 index 0000000000..386d2cfecd --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/password-input/macro.njk @@ -0,0 +1,3 @@ +{% macro govukPasswordInput(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/packages/govuk-frontend/src/govuk/components/password-input/password-input.mjs b/packages/govuk-frontend/src/govuk/components/password-input/password-input.mjs new file mode 100644 index 0000000000..5d996aa407 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/password-input/password-input.mjs @@ -0,0 +1,279 @@ +import { closestAttributeValue } from '../../common/closest-attribute-value.mjs' +import { mergeConfigs } from '../../common/index.mjs' +import { normaliseDataset } from '../../common/normalise-dataset.mjs' +import { ElementError } from '../../errors/index.mjs' +import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs' +import { I18n } from '../../i18n.mjs' + +/** + * Password input component + * + * @preserve + */ +export class PasswordInput extends GOVUKFrontendComponent { + /** @private */ + $module + + /** + * @private + * @type {PasswordInputConfig} + */ + config + + /** @private */ + i18n + + /** + * @private + * @type {HTMLInputElement} + */ + $input + + /** + * @private + * @type {HTMLButtonElement} + */ + $showHideButton + + /** @private */ + $screenReaderStatusMessage + + /** + * @param {Element | null} $module - HTML element to use for password input + * @param {PasswordInputConfig} [config] - Password input config + */ + constructor($module, config = {}) { + super() + + if (!($module instanceof HTMLElement)) { + throw new ElementError({ + componentName: 'Password input', + element: $module, + identifier: 'Root element (`$module`)' + }) + } + + const $input = $module.querySelector('.govuk-js-password-input-input') + if (!($input instanceof HTMLInputElement)) { + throw new ElementError({ + componentName: 'Password input', + element: $input, + expectedType: 'HTMLInputElement', + identifier: 'Form field (`.govuk-js-password-input-input`)' + }) + } + + if ($input.type !== 'password') { + throw new ElementError( + 'Password input: Form field (`.govuk-js-password-input-input`) must be of type `password`.' + ) + } + + const $showHideButton = $module.querySelector( + '.govuk-js-password-input-toggle' + ) + if (!($showHideButton instanceof HTMLButtonElement)) { + throw new ElementError({ + componentName: 'Password input', + element: $showHideButton, + expectedType: 'HTMLButtonElement', + identifier: 'Button (`.govuk-js-password-input-toggle`)' + }) + } + + if ($showHideButton.type !== 'button') { + throw new ElementError( + 'Password input: Button (`.govuk-js-password-input-toggle`) must be of type `button`.' + ) + } + + this.$module = $module + this.$input = $input + this.$showHideButton = $showHideButton + + this.config = mergeConfigs( + PasswordInput.defaults, + config, + normaliseDataset(PasswordInput, $module.dataset) + ) + + this.i18n = new I18n(this.config.i18n, { + // Read the fallback if necessary rather than have it set in the defaults + locale: closestAttributeValue($module, 'lang') + }) + + // Show the toggle button element + this.$showHideButton.removeAttribute('hidden') + + // Create and append the status text for screen readers. + // This is injected between the input and button so that users get a sensible reading order if + // moving through the page content linearly: + // [password input] -> [your password is visible/hidden] -> [show/hide password] + const $screenReaderStatusMessage = document.createElement('div') + $screenReaderStatusMessage.className = + 'govuk-password-input__sr-status govuk-visually-hidden' + $screenReaderStatusMessage.setAttribute('aria-live', 'polite') + this.$screenReaderStatusMessage = $screenReaderStatusMessage + this.$input.insertAdjacentElement('afterend', $screenReaderStatusMessage) + + // Bind toggle button + this.$showHideButton.addEventListener('click', this.toggle.bind(this)) + + // Bind event to revert the password visibility to hidden + if (this.$input.form) { + this.$input.form.addEventListener('submit', () => this.hide()) + } + + // If the page is restored from bfcache and the password is visible, hide it again + window.addEventListener('pageshow', (event) => { + if (event.persisted && this.$input.type !== 'password') { + this.hide() + } + }) + + // Default the component to having the password hidden. + this.hide() + } + + /** + * Toggle the visibility of the password input + * + * @private + * @param {MouseEvent} event - Click event + */ + toggle(event) { + event.preventDefault() + + // If on this click, the field is type="password", show the value + if (this.$input.type === 'password') { + this.show() + return + } + + // Otherwise, hide it + // Being defensive - hiding should always be the default + this.hide() + } + + /** + * Show the password input value in plain text. + * + * @private + */ + show() { + this.setType('text') + } + + /** + * Hide the password input value. + * + * @private + */ + hide() { + this.setType('password') + } + + /** + * Set the password input type + * + * @param {'text' | 'password'} type - Input type + * @private + */ + setType(type) { + if (type === this.$input.type) { + return + } + + // Update input type + this.$input.setAttribute('type', type) + + const isHidden = type === 'password' + const prefixButton = isHidden ? 'show' : 'hide' + const prefixStatus = isHidden ? 'passwordHidden' : 'passwordShown' + + // Update button text + this.$showHideButton.innerHTML = this.i18n.t(`${prefixButton}Password`) + + // Update button aria-label + this.$showHideButton.setAttribute( + 'aria-label', + this.i18n.t(`${prefixButton}PasswordAriaLabel`) + ) + + // Update status change text + this.$screenReaderStatusMessage.innerText = this.i18n.t( + `${prefixStatus}Announcement` + ) + } + + /** + * Name for the component used when initialising using data-module attributes. + */ + static moduleName = 'govuk-password-input' + + /** + * Password input default config + * + * @see {@link PasswordInputConfig} + * @constant + * @default + * @type {PasswordInputConfig} + */ + static defaults = Object.freeze({ + i18n: { + showPassword: 'Show', + hidePassword: 'Hide', + showPasswordAriaLabel: 'Show password', + hidePasswordAriaLabel: 'Hide password', + passwordShownAnnouncement: 'Your password is visible', + passwordHiddenAnnouncement: 'Your password is hidden' + } + }) + + /** + * Password input config schema + * + * @constant + * @satisfies {Schema} + */ + static schema = Object.freeze({ + properties: { + i18n: { type: 'object' } + } + }) +} + +/** + * Password input config + * + * @typedef {object} PasswordInputConfig + * @property {PasswordInputTranslations} [i18n=PasswordInput.defaults.i18n] - Password input translations + */ + +/** + * Password input translations + * + * @see {@link PasswordInput.defaults.i18n} + * @typedef {object} PasswordInputTranslations + * + * Messages displayed to the user indicating the state of the show/hide toggle. + * @property {string} [showPassword] - Visible text of the button when the + * password is currently hidden. HTML is acceptable. + * @property {string} [hidePassword] - Visible text of the button when the + * password is currently visible. HTML is acceptable. + * @property {string} [showPasswordAriaLabel] - aria-label of the button when + * the password is currently hidden. Plain text only. + * @property {string} [hidePasswordAriaLabel] - aria-label of the button when + * the password is currently visible. Plain text only. + * @property {string} [passwordShownAnnouncement] - Screen reader + * announcement to make when the password has just become visible. + * Plain text only. + * @property {string} [passwordHiddenAnnouncement] - Screen reader + * announcement to make when the password has just been hidden. + * Plain text only. + */ + +/** + * @typedef {import('../../common/index.mjs').Schema} Schema + * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms + */ diff --git a/packages/govuk-frontend/src/govuk/components/password-input/password-input.puppeteer.test.js b/packages/govuk-frontend/src/govuk/components/password-input/password-input.puppeteer.test.js new file mode 100644 index 0000000000..b60cb84fd5 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/password-input/password-input.puppeteer.test.js @@ -0,0 +1,440 @@ +const { render, goTo } = require('@govuk-frontend/helpers/puppeteer') +const { getExamples } = require('@govuk-frontend/lib/components') + +const inputSelector = '.govuk-js-password-input-input' +const buttonSelector = '.govuk-js-password-input-toggle' +const statusSelector = '.govuk-password-input__sr-status' + +describe('/components/password-input', () => { + let examples + + beforeAll(async () => { + examples = await getExamples('password-input') + }) + + describe('/components/password-input/preview', () => { + describe('when JavaScript is unavailable or fails', () => { + beforeAll(async () => { + await page.setJavaScriptEnabled(false) + }) + + afterAll(async () => { + await page.setJavaScriptEnabled(true) + }) + + it('still renders an unmodified password input', async () => { + await render(page, 'password-input', examples.default) + + const inputType = await page.$eval(inputSelector, (el) => + el.getAttribute('type') + ) + expect(inputType).toBe('password') + }) + + it('renders the toggle button hidden', async () => { + await render(page, 'password-input', examples.default) + + const buttonHiddenAttribute = await page.$eval(buttonSelector, (el) => + el.hasAttribute('hidden') + ) + expect(buttonHiddenAttribute).toBeTruthy() + }) + }) + + describe('when JavaScript is available', () => { + describe('on page load', () => { + beforeAll(async () => { + await render(page, 'password-input', examples.default) + }) + + it('renders the status element', async () => { + const statusElement = await page.$eval(statusSelector, (el) => el) + + expect(statusElement).toBeDefined() + }) + + it('renders the status element with aria-live', async () => { + const statusAriaLiveAttribute = await page.$eval( + statusSelector, + (el) => el.getAttribute('aria-live') + ) + + expect(statusAriaLiveAttribute).toBe('polite') + }) + + it('renders the status element empty', async () => { + const statusText = await page.$eval(statusSelector, (el) => + el.innerHTML.trim() + ) + + expect(statusText).toBe('') + }) + + it('shows the toggle button', async () => { + const buttonHiddenAttribute = await page.$eval(buttonSelector, (el) => + el.hasAttribute('hidden') + ) + + expect(buttonHiddenAttribute).toBeFalsy() + }) + }) + + describe.each([ + [1, itShowsThePassword], + [2, itHidesThePassword], + [3, itShowsThePassword] + ])('when clicked %i time(s)', (clicks, expectation) => { + beforeAll(async () => { + await render(page, 'password-input', examples.default) + for (let i = 0; i < clicks; i++) { + await page.click(buttonSelector) + } + }) + + expectation() + }) + + describe('when the form is submitted', () => { + it('reverts the input back to password type', async () => { + // Go to the full-page example + await goTo(page, `/full-page-examples/update-your-account-details`) + + // Prevent form submissions so that we don't navigate away during the test + await page.evaluate(() => { + document.addEventListener('submit', (e) => e.preventDefault()) + }) + + // Type something into the email field + await page.type('[type="email"]', 'test@example.com') + + // Type something into the password field + await page.type('[type="password"]', 'Hunter2') + + // Click the "show" button so the password is visible in plain text + await page.click(buttonSelector) + + // Check that the type change has occurred as expected + const beforeSubmitType = await page.$eval(inputSelector, (el) => + el.getAttribute('type') + ) + + // Submit the form + await page.click('[type="submit"]') + + // Check the input type again + const afterSubmitType = await page.$eval(inputSelector, (el) => + el.getAttribute('type') + ) + + expect(beforeSubmitType).toBe('text') + expect(afterSubmitType).toBe('password') + }) + }) + + describe('i18n', () => { + it('uses the correct translations when the password is visible', async () => { + await render(page, 'password-input', examples['with translations']) + await page.click(buttonSelector) + + const statusText = await page.$eval(statusSelector, (el) => + el.innerHTML.trim() + ) + const buttonText = await page.$eval(buttonSelector, (el) => + el.innerHTML.trim() + ) + const buttonAriaLabel = await page.$eval(buttonSelector, (el) => + el.getAttribute('aria-label') + ) + + // Expect: passwordShownAnnouncementText + expect(statusText).toBe('Mae eich cyfrinair yn weladwy.') + + // Expect: hidePasswordText + expect(buttonText).toBe('Cuddio') + + // Expect: hidePasswordAriaLabelText + expect(buttonAriaLabel).toBe('Cuddio cyfrinair') + }) + + it('uses the correct translations when the password is hidden', async () => { + await render(page, 'password-input', examples['with translations']) + + // This test clicks the toggle twice because the status element is not populated when + // the component is initialised, it only becomes populated after the first toggle. + await page.click(buttonSelector) + await page.click(buttonSelector) + + const statusText = await page.$eval(statusSelector, (el) => + el.innerHTML.trim() + ) + const buttonText = await page.$eval(buttonSelector, (el) => + el.innerHTML.trim() + ) + const buttonAriaLabel = await page.$eval(buttonSelector, (el) => + el.getAttribute('aria-label') + ) + + // Expect: passwordHiddenAnnouncementText + expect(statusText).toBe("Mae eich cyfrinair wedi'i guddio.") + + // Expect: showPasswordText + expect(buttonText).toBe('Datguddia') + + // Expect: showPasswordAriaLabelText + expect(buttonAriaLabel).toBe('Datgelu cyfrinair') + }) + }) + + describe('errors at instantiation', () => { + it('can throw a SupportError if appropriate', async () => { + await expect( + render(page, 'password-input', examples.default, { + beforeInitialisation() { + document.body.classList.remove('govuk-frontend-supported') + } + }) + ).rejects.toMatchObject({ + cause: { + name: 'SupportError', + message: + 'GOV.UK Frontend initialised without `` from template `