Skip to content

Commit

Permalink
Prototype password input component
Browse files Browse the repository at this point in the history
This is a (mostly) straight port of the JavaScript in GOV.UK Publishing Components combined with the text input component, with some minor
alterations:

- JavaScript has been modified to fit the Design System's coding and documentation conventions.
- Support for configuration and localisation strings has been added.
- The function to move specific data-attributes has been omitted for the time being.
- Custom button styles have not been ported, instead using those from the Design System.
- Configuration options (via JS or data-attributes) haven't been hooked up just yet.
  • Loading branch information
querkmachine committed Nov 7, 2023
1 parent 4d13a75 commit 83a98bb
Show file tree
Hide file tree
Showing 11 changed files with 391 additions and 1 deletion.
4 changes: 3 additions & 1 deletion packages/govuk-frontend/src/govuk/all.jsdom.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jest.mock(`./components/checkboxes/checkboxes.mjs`)
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/input/input-password.mjs`)
jest.mock(`./components/notification-banner/notification-banner.mjs`)
jest.mock(`./components/radios/radios.mjs`)
jest.mock(`./components/skip-link/skip-link.mjs`)
Expand All @@ -27,7 +28,8 @@ describe('initAll', () => {
'character-count',
'error-summary',
'exit-this-page',
'notification-banner'
'notification-banner',
'password-input'
]

afterEach(() => {
Expand Down
5 changes: 5 additions & 0 deletions packages/govuk-frontend/src/govuk/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -41,6 +42,7 @@ function initAll(config) {
[ExitThisPage, config.exitThisPage],
[Header],
[NotificationBanner, config.notificationBanner],
[PasswordInput, config.passwordInput],
[Radios],
[SkipLink],
[Tabs]
Expand Down Expand Up @@ -81,6 +83,7 @@ export {
ExitThisPage,
Header,
NotificationBanner,
PasswordInput,
Radios,
SkipLink,
Tabs
Expand All @@ -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
*/

/**
Expand All @@ -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
*/

/**
Expand Down
1 change: 1 addition & 0 deletions packages/govuk-frontend/src/govuk/components/_all.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe('GOV.UK Frontend', () => {
'ExitThisPage',
'Header',
'NotificationBanner',
'PasswordInput',
'Radios',
'SkipLink',
'Tabs'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@import "../button/index";
@import "../error-message/index";
@import "../hint/index";
@import "../input/index";
@import "../label/index";

@include govuk-exports("govuk/component/password-input") {
.govuk-password-input {
display: flex;
width: 100%;
flex-direction: column;

@include govuk-media-query($from: mobile) {
flex-direction: row;
}
}

.govuk-password-input__toggle {
margin-bottom: 0;

// Buttons are normally 100% width on this breakpoint, but we don't want that in this case
@include govuk-media-query($from: mobile) {
width: auto;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@import "../../base";
@import "./index";
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% macro govukPasswordInput(params) %}
{%- include "./template.njk" -%}
{% endmacro %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { closestAttributeValue } from '../../common/closest-attribute-value.mjs'
import {
extractConfigByNamespace,
mergeConfigs,
validateConfig
} from '../../common/index.mjs'
import { normaliseDataset } from '../../common/normalise-dataset.mjs'
import { ConfigError, 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
* @type {HTMLElement | null}
*/
$showHideButton = null

/**
* @private
* @type {HTMLElement | null}
*/
$statusText = null

/**
* @param {Element} $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`)'
})
}

this.$wrapper = $module
this.$input = $module.querySelector('input')

if (!(this.$input instanceof HTMLInputElement)) {
throw new ElementError({
componentName: 'Password input',
element: this.$input,
expectedType: 'HTMLInputElement',
identifier: 'Form field (`.govuk-password-input`)'
})
}

this.config = mergeConfigs(
PasswordInput.defaults,
config || {},
normaliseDataset($module.dataset)
)

// Check for valid config
const errors = validateConfig(PasswordInput.schema, this.config)
if (errors[0]) {
throw new ConfigError(`Password input: ${errors[0]}`)
}

this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
// Read the fallback if necessary rather than have it set in the defaults
locale: closestAttributeValue($module, 'lang')
})

// Create and append the button element
const $showHideButton = document.createElement('button')
$showHideButton.className =
'govuk-button govuk-button--secondary govuk-password-input__toggle'
$showHideButton.setAttribute(
'aria-controls',
this.$input.getAttribute('id')
)
$showHideButton.setAttribute('type', 'button')
$showHideButton.setAttribute(
'aria-label',
this.i18n.t('showPasswordFullText')
)
$showHideButton.innerHTML = this.i18n.t('showPassword')
this.$showHideButton = $showHideButton
this.$wrapper.insertBefore($showHideButton, this.$input.nextSibling)

// Create and append the status text for screen readers
const $statusText = document.createElement('span')
$statusText.className = 'govuk-visually-hidden'
$statusText.innerText = this.i18n.t('hiddenPasswordAnnouncement')
$statusText.setAttribute('aria-live', 'polite')
this.$statusText = $statusText
this.$wrapper.insertBefore($statusText, this.$input.nextSibling)

// Bind toggle button
this.$showHideButton.addEventListener(
'click',
this.togglePassword.bind(this)
)

// Bind form submit check, unless it's been disabled
if (this.$input.form && !this.config.disableFormSubmitCheck) {
this.$input.form.addEventListener('submit', () =>
this.revertToPasswordOnFormSubmit()
)
}
}

/**
* @param {MouseEvent} event -
*/
togglePassword(event) {
event.preventDefault()
this.$input.setAttribute(
'type',
this.$input.type === 'password' ? 'text' : 'password'
)
const passwordIsHidden = this.$input.type === 'password'
this.$showHideButton.innerHTML = passwordIsHidden
? this.i18n.t('showPassword')
: this.i18n.t('hidePassword')
this.$showHideButton.setAttribute(
'aria-label',
passwordIsHidden
? this.i18n.t('showPasswordFullText')
: this.i18n.t('hidePasswordFullText')
)
this.$statusText.innerText = passwordIsHidden
? this.i18n.t('hiddenPasswordAnnouncement')
: this.i18n.t('shownPasswordAnnouncement')
}

/**
* Revert the input to type=password when the form is submitted. This prevents
* user agents potentially saving or caching the plain text password.
*/
revertToPasswordOnFormSubmit() {
this.$showHideButton.setAttribute(
'aria-label',
this.i18n.t('showPasswordFullText')
)
this.$showHideButton.innerHTML = this.i18n.t('showPassword')
this.$statusText.innerText = this.i18n.t('hiddenPasswordAnnouncement')
this.$input.setAttribute('type', 'password')
}

/**
* 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({
disableFormSubmitCheck: false,
i18n: {
showPassword: 'Show',
hidePassword: 'Hide',
showPasswordFullText: 'Show password',
hidePasswordFullText: 'Hide password',
shownPasswordAnnouncement: 'Your password is visible',
hiddenPasswordAnnouncement: 'Your password is hidden'
}
})

/**
* Character count config schema
*
* @constant
* @satisfies {Schema}
*/
static schema = Object.freeze({})
}

/**
* Password input config
*
* @typedef {object} PasswordInputConfig
* @property {boolean} [disableFormSubmitCheck=false] - If set to `true` the
* password input will not automatically change back to the `password` type
* upon submission of the parent form.
* @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} [showPasswordFullText] - aria-label of the button when
* the password is currently hidden. Plain text only.
* @property {string} [hidePasswordFullText] - aria-label of the button when
* the password is currently visible. Plain text only.
* @property {string} [shownPasswordAnnouncement] - Screen reader
* announcement to make when the password has just become visible.
* Plain text only.
* @property {string} [hiddenPasswordAnnouncement] - Screen reader
* announcement to make when the password has just been hidden.
* Plain text only.
*/

/**
* @typedef {import('../../common/index.mjs').Schema} Schema
*/
Loading

0 comments on commit 83a98bb

Please sign in to comment.