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 %}
+
+
{% 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 `