Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create password input component #4442

Merged
merged 46 commits into from
Mar 15, 2024
Merged

Conversation

querkmachine
Copy link
Member

@querkmachine querkmachine commented Nov 7, 2023

Prototypical password input component for #4225.

See it in the review app.

Changes

  • Adds a new component: Password input.
    • This is a (mostly) straight port of the component in GOV.UK Publishing Components 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 option to disable automatically switching the input back to a password type upon form submission has been removed. We couldn't find any evidence that this was being used by any live service and it doesn't serve a clear user need, so we've chosen to omit it unless a need arises.
      • The function to move data-attributes from the input to the toggle button has been omitted.
        • I think this code primarily existed for use with analytics event tracking, which isn't normally a use case we account for in Design System components, but I'm interested if there's other uses where we might want this.
        • This has probably been superseded anyway by the button existing as a configurable sub-component rather than being purely injected by JS.
      • Custom button styles have not been ported, instead using the button from the Design System.
  • Updates all.mjs to export the Password input and automatically use it if data-module="govuk-password-input" is found on a page.
  • Updates the Prototype Kit configuration to export the Password input.
  • Adds tests for new component and altered functionality elsewhere.

Changes to other components

  • The Text input component has had a new autocapitalize attribute and parameter added.
  • The text input component now has an inputWrapper parameter that allows for setting classes and attributes on the govuk-input__wrapper element.

Changes to the review app

  • Adds a new "Sign in to a service" full-page example with the password input component with the current-password autocomplete value.
  • Updates the "Update your account details" full-page example to use the password input component with the new-password autocomplete value.

Thoughts

  • The input type is hardcoded to password in the initial HTML, as I don't envisage this practically being anything else.
  • For accessible authentication reasons the input should virtually always have an autocomplete attribute. As such, I've made it a required attribute that defaults to current-password.
  • The spellcheck and autocapitalize attributes are both set to off/disabled. This is to prevent the user's device attempting to 'correct' the user's input if they are typing a password whilst the input is displaying as plain text.
  • Various parameters that are available on the text input component—such as pattern, inputmode and prefix/suffixes—are not accessible via the Password input. I think these have limited application when it comes to passwords and could only be used in ways we would typically advise against (e.g. client-side validation, number-only passwords).

Copy link

github-actions bot commented Nov 7, 2023

📋 Stats

File sizes

File Size
dist/govuk-frontend-development.min.css 113.25 KiB
dist/govuk-frontend-development.min.js 42.21 KiB
packages/govuk-frontend/dist/govuk/all.bundle.js 87.21 KiB
packages/govuk-frontend/dist/govuk/all.bundle.mjs 81.94 KiB
packages/govuk-frontend/dist/govuk/all.mjs 4.17 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend-component.mjs 359 B
packages/govuk-frontend/dist/govuk/govuk-frontend.min.css 113.24 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.js 42.2 KiB
packages/govuk-frontend/dist/govuk/i18n.mjs 5.55 KiB

Modules

File Size (bundled) Size (minified)
all.mjs 77.67 KiB 40.18 KiB
accordion.mjs 22.71 KiB 12.85 KiB
button.mjs 5.98 KiB 2.69 KiB
character-count.mjs 22.4 KiB 9.92 KiB
checkboxes.mjs 5.83 KiB 2.83 KiB
error-summary.mjs 7.89 KiB 3.46 KiB
exit-this-page.mjs 17.1 KiB 9.26 KiB
header.mjs 4.46 KiB 2.6 KiB
notification-banner.mjs 6.26 KiB 2.62 KiB
password-input.mjs 15.15 KiB 7.25 KiB
radios.mjs 4.83 KiB 2.38 KiB
skip-link.mjs 4.39 KiB 2.18 KiB
tabs.mjs 10.13 KiB 6.11 KiB

View stats and visualisations on the review app


Action run for 2b5d976

@Ciandelle Ciandelle marked this pull request as ready for review November 7, 2023 17:26
@querkmachine querkmachine marked this pull request as draft November 9, 2023 10:06
Copy link
Contributor

@owenatgov owenatgov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have loads to add! I haven't looked at the code in great depth as this is just a prototype but it all seems to work as expected.

The button thing is tricky but not one for right now, I agree. It looks disturbing enough that it's a problem so we should solve it eventually.

From memory, the reason the original was designed the way it was was to distinguish between other potential buttons in a journey. A secondary button that's adjusted to line up with the input is probably fine. From poking around in DI's journeies, it looks like this is only used on pages where there's a primary button as a CTA.

Personally I wonder if we should consider our button ecosystem and see if we should add to it. I wonder if this is something we ask from the working group during the first round.

@querkmachine
Copy link
Member Author

I've also heard rumours that the appearance of the secondary button in the Design System isn't favoured because it looks like it might be disabled (given it's all light grey / monochrome). I can't be sure of the veracity of said rumours, but it seems like a fair criticism of the current design.

@owenatgov
Copy link
Contributor

A couple of extra things from chatting to @andysellick:

  1. We reckon that my hypothesis was correct that this was to avoid visual clashes with primary buttons on a given view. Why it looks like a link inside a box is still unclear and probably a best effort to solve the problem by designers at the time. I think we have licence to change it.
  2. Is there value in us putting together a login page full page example to demo this component in situ to assess it better? @wilsond-gds shared some example auth journey views with us on slack, we could probably pick one of them with his guidance.

@querkmachine querkmachine changed the title [SPIKE] Password input component [SPIKE] Password input component - standalone approach Dec 8, 2023
@36degrees
Copy link
Contributor

For accessible authentication reasons the input should virtually always have an autocomplete attribute. As such, I've made it a required attribute that defaults to current-password. I've added a newPassword boolean parameter to switch it to new-password when needed.

I think the exception to this would be when setting the password for another user, e.g. as part of user management in a case working or admin system – in which case the autocomplete attribute should not be used.

I think it does make sense to default to current-password, but I think we should just make autocomplete an option and ensure it can be omitted entirely.

The spellcheck and autocapitalize attributes are both set to off/disabled. This is to prevent the user's device attempting to 'correct' the user's input if they are typing a password whilst the input is displaying as plain text.

The autocapitalize attribute is explicitly disabled for password inputs in the spec.

I’d say spellcheck is implicitly off too, based on this part of the spec (emphasis mine):

User agents must only consider the following pieces of text as checkable for the purposes of this feature:

If we know there are browsers that don’t follow the spec correctly on either of those attributes (i.e. they incorrectly autocapitalize or spellcheck them) then we could consider adding them. Otherwise, we shouldn't omit them as they shouldn't be necessary.

@querkmachine
Copy link
Member Author

I think the exception to this would be when setting the password for another user, e.g. as part of user management in a case working or admin system – in which case the autocomplete attribute should not be used.

I think it does make sense to default to current-password, but I think we should just make autocomplete an option and ensure it can be omitted entirely.

User agents use their own heuristics to decide that a password input should be automatically fillable (e.g. based on the input type or the associated label) and many will still present the option to autofill even if the autocomplete attribute is omitted or set to off.

Given the autofill behaviour is almost always going to be presented to a user anyway, my thinking is that it's better to be explicit, rather than omitting it and potentially presenting the wrong thing (e.g. the option to autofill a new password field).

The autocapitalize attribute is explicitly disabled for password inputs in the spec.

I’d say spellcheck is implicitly off too, based on this part of the spec (emphasis mine):

The attributes are primarily to avoid those issues when the password has been made visible, such as if the toggle feature is being used for data entry and not just reviewing input after the fact.

@36degrees
Copy link
Contributor

I think the exception to this would be when setting the password for another user, e.g. as part of user management in a case working or admin system – in which case the autocomplete attribute should not be used.
I think it does make sense to default to current-password, but I think we should just make autocomplete an option and ensure it can be omitted entirely.

User agents use their own heuristics to decide that a password input should be automatically fillable (e.g. based on the input type or the associated label) and many will still present the option to autofill even if the autocomplete attribute is omitted or set to off.

Given the autofill behaviour is almost always going to be presented to a user anyway, my thinking is that it's better to be explicit, rather than omitting it and potentially presenting the wrong thing (e.g. the option to autofill a new password field).

Just to make sure we're on the same page, happy with defaulting to current-password, but I do think users should be able to set the autocomplete attribute to anything, rather than just having the newPassword boolean.

If we really think constraining to only current-password / new-password is the right thing to do, my preference would be to have an autocomplete option that only accepts those two options, rather than a boolean. Boolean options can become problematic when you want to introduce additional options in the future, as you end up with competing booleans where the behaviour is hard to define.

The autocapitalize attribute is explicitly disabled for password inputs in the spec.
I’d say spellcheck is implicitly off too, based on this part of the spec (emphasis mine):

The attributes are primarily to avoid those issues when the password has been made visible, such as if the toggle feature is being used for data entry and not just reviewing input after the fact.

Ah, of course! 🤦🏻 That makes sense, thanks for explaining. It might be worth adding a comment along those lines?

@querkmachine
Copy link
Member Author

Just to make sure we're on the same page, happy with defaulting to current-password, but I do think users should be able to set the autocomplete attribute to anything, rather than just having the newPassword boolean.

If we really think constraining to only current-password / new-password is the right thing to do, my preference would be to have an autocomplete option that only accepts those two options, rather than a boolean. Boolean options can become problematic when you want to introduce additional options in the future, as you end up with competing booleans where the behaviour is hard to define.

Yeah, fair point on the boolean parameter.

Regarding constraints, I guess the question is what other autocomplete values would we expect? For the purposes of the Accessible Authentication criterion the only compliant ones that currently exist would seem to be current-password and new-password. Any other value would either bypass or undermine the goals of the component.

one-time-code is a maybe, I guess, though the guidance I've been drafting explicitly warns against using the password input for OTPs, as there isn't a significant practical or security benefit to them being hidden.

webauthn is another possibility for services with passkeys, though the guidance I've seen says this value is better placed on the username or email field rather than the password field. (The goal of passkeys is to replace passwords, after all.)

On that basis I personally wouldn't have a problem with constraining the value to being one of current-password or new-password.

Copy link

github-actions bot commented Dec 19, 2023

JavaScript changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
index 4fa94bfc7..a9132bf77 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
@@ -1,44 +1,44 @@
 const version = "development";
 
 function normaliseString(e, t) {
-    const n = e ? e.trim() : "";
-    let i, s = null == t ? void 0 : t.type;
-    switch (s || (["true", "false"].includes(n) && (s = "boolean"), n.length > 0 && isFinite(Number(n)) && (s = "number")), s) {
+    const s = e ? e.trim() : "";
+    let n, i = null == t ? void 0 : t.type;
+    switch (i || (["true", "false"].includes(s) && (i = "boolean"), s.length > 0 && isFinite(Number(s)) && (i = "number")), i) {
         case "boolean":
-            i = "true" === n;
+            n = "true" === s;
             break;
         case "number":
-            i = Number(n);
+            n = Number(s);
             break;
         default:
-            i = e
+            n = e
     }
-    return i
+    return n
 }
 
 function mergeConfigs(...e) {
     const t = {};
-    for (const n of e)
-        for (const e of Object.keys(n)) {
-            const i = t[e],
-                s = n[e];
-            isObject(i) && isObject(s) ? t[e] = mergeConfigs(i, s) : t[e] = s
+    for (const s of e)
+        for (const e of Object.keys(s)) {
+            const n = t[e],
+                i = s[e];
+            isObject(n) && isObject(i) ? t[e] = mergeConfigs(n, i) : t[e] = i
         }
     return t
 }
 
-function extractConfigByNamespace(e, t, n) {
-    const i = e.schema.properties[n];
-    if ("object" !== (null == i ? void 0 : i.type)) return;
-    const s = {
-        [n]: {}
+function extractConfigByNamespace(e, t, s) {
+    const n = e.schema.properties[s];
+    if ("object" !== (null == n ? void 0 : n.type)) return;
+    const i = {
+        [s]: {}
     };
     for (const [o, r] of Object.entries(t)) {
-        let e = s;
+        let e = i;
         const t = o.split(".");
-        for (const [i, s] of t.entries()) "object" == typeof e && (i < t.length - 1 ? (isObject(e[s]) || (e[s] = {}), e = e[s]) : o !== n && (e[s] = normaliseString(r)))
+        for (const [n, i] of t.entries()) "object" == typeof e && (n < t.length - 1 ? (isObject(e[i]) || (e[i] = {}), e = e[i]) : o !== s && (e[i] = normaliseString(r)))
     }
-    return s[n]
+    return i[s]
 }
 
 function getFragmentFromUrl(e) {
@@ -54,20 +54,20 @@ function getBreakpoint(e) {
 }
 
 function setFocus(e, t = {}) {
-    var n;
-    const i = e.getAttribute("tabindex");
+    var s;
+    const n = e.getAttribute("tabindex");
 
     function onBlur() {
-        var n;
-        null == (n = t.onBlur) || n.call(e), i || e.removeAttribute("tabindex")
+        var s;
+        null == (s = t.onBlur) || s.call(e), n || e.removeAttribute("tabindex")
     }
-    i || e.setAttribute("tabindex", "-1"), e.addEventListener("focus", (function() {
+    n || e.setAttribute("tabindex", "-1"), e.addEventListener("focus", (function() {
         e.addEventListener("blur", onBlur, {
             once: !0
         })
     }), {
         once: !0
-    }), null == (n = t.onBeforeFocus) || n.call(e), e.focus()
+    }), null == (s = t.onBeforeFocus) || s.call(e), e.focus()
 }
 
 function isSupported(e = document.body) {
@@ -81,9 +81,9 @@ function isObject(e) {
 }
 
 function normaliseDataset(e, t) {
-    const n = {};
-    for (const [i, s] of Object.entries(e.schema.properties)) i in t && (n[i] = normaliseString(t[i], s)), "object" === (null == s ? void 0 : s.type) && (n[i] = extractConfigByNamespace(e, t, i));
-    return n
+    const s = {};
+    for (const [n, i] of Object.entries(e.schema.properties)) n in t && (s[n] = normaliseString(t[n], i)), "object" === (null == i ? void 0 : i.type) && (s[n] = extractConfigByNamespace(e, t, n));
+    return s
 }
 class GOVUKFrontendError extends Error {
     constructor(...e) {
@@ -106,12 +106,12 @@ class ElementError extends GOVUKFrontendError {
         let t = "string" == typeof e ? e : "";
         if ("object" == typeof e) {
             const {
-                componentName: n,
-                identifier: i,
-                element: s,
+                componentName: s,
+                identifier: n,
+                element: i,
                 expectedType: o
             } = e;
-            t = `${n}: ${i}`, t += s ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found"
+            t = `${s}: ${n}`, t += i ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found"
         }
         super(t), this.name = "ElementError"
     }
@@ -126,31 +126,31 @@ class GOVUKFrontendComponent {
 }
 class I18n {
     constructor(e = {}, t = {}) {
-        var n;
-        this.translations = void 0, this.locale = void 0, this.translations = e, this.locale = null != (n = t.locale) ? n : document.documentElement.lang || "en"
+        var s;
+        this.translations = void 0, this.locale = void 0, this.translations = e, this.locale = null != (s = t.locale) ? s : document.documentElement.lang || "en"
     }
     t(e, t) {
         if (!e) throw new Error("i18n: lookup key missing");
-        let n = this.translations[e];
-        if ("number" == typeof(null == t ? void 0 : t.count) && "object" == typeof n) {
-            const i = n[this.getPluralSuffix(e, t.count)];
-            i && (n = i)
+        let s = this.translations[e];
+        if ("number" == typeof(null == t ? void 0 : t.count) && "object" == typeof s) {
+            const n = s[this.getPluralSuffix(e, t.count)];
+            n && (s = n)
         }
-        if ("string" == typeof n) {
-            if (n.match(/%{(.\S+)}/)) {
+        if ("string" == typeof s) {
+            if (s.match(/%{(.\S+)}/)) {
                 if (!t) throw new Error("i18n: cannot replace placeholders in string if no option data provided");
-                return this.replacePlaceholders(n, t)
+                return this.replacePlaceholders(s, t)
             }
-            return n
+            return s
         }
         return e
     }
     replacePlaceholders(e, t) {
-        const n = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : void 0;
-        return e.replace(/%{(.\S+)}/g, (function(e, i) {
-            if (Object.prototype.hasOwnProperty.call(t, i)) {
-                const e = t[i];
-                return !1 === e || "number" != typeof e && "string" != typeof e ? "" : "number" == typeof e ? n ? n.format(e) : `${e}` : e
+        const s = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : void 0;
+        return e.replace(/%{(.\S+)}/g, (function(e, n) {
+            if (Object.prototype.hasOwnProperty.call(t, n)) {
+                const e = t[n];
+                return !1 === e || "number" != typeof e && "string" != typeof e ? "" : "number" == typeof e ? s ? s.format(e) : `${e}` : e
             }
             throw new Error(`i18n: no data found to replace ${e} placeholder in string`)
         }))
@@ -160,11 +160,11 @@ class I18n {
     }
     getPluralSuffix(e, t) {
         if (t = Number(t), !isFinite(t)) return "other";
-        const n = this.translations[e],
-            i = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(t) : this.selectPluralFormUsingFallbackRules(t);
-        if ("object" == typeof n) {
-            if (i in n) return i;
-            if ("other" in n) return console.warn(`i18n: Missing plural form ".${i}" for "${this.locale}" locale. Falling back to ".other".`), "other"
+        const s = this.translations[e],
+            n = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(t) : this.selectPluralFormUsingFallbackRules(t);
+        if ("object" == typeof s) {
+            if (n in s) return n;
+            if ("other" in s) return console.warn(`i18n: Missing plural form ".${n}" for "${this.locale}" locale. Falling back to ".other".`), "other"
         }
         throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`)
     }
@@ -176,8 +176,8 @@ class I18n {
     getPluralRulesForLocale() {
         const e = this.locale.split("-")[0];
         for (const t in I18n.pluralRulesMap) {
-            const n = I18n.pluralRulesMap[t];
-            if (n.includes(this.locale) || n.includes(e)) return t
+            const s = I18n.pluralRulesMap[t];
+            if (s.includes(this.locale) || s.includes(e)) return t
         }
     }
 }
@@ -199,29 +199,29 @@ I18n.pluralRulesMap = {
     irish: e => 1 === e ? "one" : 2 === e ? "two" : e >= 3 && e <= 6 ? "few" : e >= 7 && e <= 10 ? "many" : "other",
     russian(e) {
         const t = e % 100,
-            n = t % 10;
-        return 1 === n && 11 !== t ? "one" : n >= 2 && n <= 4 && !(t >= 12 && t <= 14) ? "few" : 0 === n || n >= 5 && n <= 9 || t >= 11 && t <= 14 ? "many" : "other"
+            s = t % 10;
+        return 1 === s && 11 !== t ? "one" : s >= 2 && s <= 4 && !(t >= 12 && t <= 14) ? "few" : 0 === s || s >= 5 && s <= 9 || t >= 11 && t <= 14 ? "many" : "other"
     },
     scottish: e => 1 === e || 11 === e ? "one" : 2 === e || 12 === e ? "two" : e >= 3 && e <= 10 || e >= 13 && e <= 19 ? "few" : "other",
     spanish: e => 1 === e ? "one" : e % 1e6 == 0 && 0 !== e ? "many" : "other",
     welsh: e => 0 === e ? "zero" : 1 === e ? "one" : 2 === e ? "two" : 3 === e ? "few" : 6 === e ? "many" : "other"
 };
 class Accordion extends GOVUKFrontendComponent {
-    constructor(t, n = {}) {
+    constructor(t, s = {}) {
         if (super(), this.$module = void 0, this.config = void 0, this.i18n = void 0, this.controlsClass = "govuk-accordion__controls", this.showAllClass = "govuk-accordion__show-all", this.showAllTextClass = "govuk-accordion__show-all-text", this.sectionClass = "govuk-accordion__section", this.sectionExpandedClass = "govuk-accordion__section--expanded", this.sectionButtonClass = "govuk-accordion__section-button", this.sectionHeaderClass = "govuk-accordion__section-header", this.sectionHeadingClass = "govuk-accordion__section-heading", this.sectionHeadingDividerClass = "govuk-accordion__section-heading-divider", this.sectionHeadingTextClass = "govuk-accordion__section-heading-text", this.sectionHeadingTextFocusClass = "govuk-accordion__section-heading-text-focus", this.sectionShowHideToggleClass = "govuk-accordion__section-toggle", this.sectionShowHideToggleFocusClass = "govuk-accordion__section-toggle-focus", this.sectionShowHideTextClass = "govuk-accordion__section-toggle-text", this.upChevronIconClass = "govuk-accordion-nav__chevron", this.downChevronIconClass = "govuk-accordion-nav__chevron--down", this.sectionSummaryClass = "govuk-accordion__section-summary", this.sectionSummaryFocusClass = "govuk-accordion__section-summary-focus", this.sectionContentClass = "govuk-accordion__section-content", this.$sections = void 0, this.browserSupportsSessionStorage = !1, this.$showAllButton = null, this.$showAllIcon = null, this.$showAllText = null, !(t instanceof HTMLElement)) throw new ElementError({
             componentName: "Accordion",
             element: t,
             identifier: "Root element (`$module`)"
         });
-        this.$module = t, this.config = mergeConfigs(Accordion.defaults, n, normaliseDataset(Accordion, t.dataset)), this.i18n = new I18n(this.config.i18n);
-        const i = this.$module.querySelectorAll(`.${this.sectionClass}`);
-        if (!i.length) throw new ElementError({
+        this.$module = t, this.config = mergeConfigs(Accordion.defaults, s, normaliseDataset(Accordion, t.dataset)), this.i18n = new I18n(this.config.i18n);
+        const n = this.$module.querySelectorAll(`.${this.sectionClass}`);
+        if (!n.length) throw new ElementError({
             componentName: "Accordion",
             identifier: `Sections (\`<div class="${this.sectionClass}">\`)`
         });
-        this.$sections = i, this.browserSupportsSessionStorage = e.checkForSessionStorage(), this.initControls(), this.initSectionHeaders();
-        const s = this.checkIfAllSectionsOpen();
-        this.updateShowAllButton(s)
+        this.$sections = n, this.browserSupportsSessionStorage = e.checkForSessionStorage(), this.initControls(), this.initSectionHeaders();
+        const i = this.checkIfAllSectionsOpen();
+        this.updateShowAllButton(i)
     }
     initControls() {
         this.$showAllButton = document.createElement("button"), this.$showAllButton.setAttribute("type", "button"), this.$showAllButton.setAttribute("class", this.showAllClass), this.$showAllButton.setAttribute("aria-expanded", "false"), this.$showAllIcon = document.createElement("span"), this.$showAllIcon.classList.add(this.upChevronIconClass), this.$showAllButton.appendChild(this.$showAllIcon);
@@ -230,53 +230,53 @@ class Accordion extends GOVUKFrontendComponent {
     }
     initSectionHeaders() {
         this.$sections.forEach(((e, t) => {
-            const n = e.querySelector(`.${this.sectionHeaderClass}`);
-            if (!n) throw new ElementError({
+            const s = e.querySelector(`.${this.sectionHeaderClass}`);
+            if (!s) throw new ElementError({
                 componentName: "Accordion",
                 identifier: `Section headers (\`<div class="${this.sectionHeaderClass}">\`)`
             });
-            this.constructHeaderMarkup(n, t), this.setExpanded(this.isExpanded(e), e), n.addEventListener("click", (() => this.onSectionToggle(e))), this.setInitialState(e)
+            this.constructHeaderMarkup(s, t), this.setExpanded(this.isExpanded(e), e), s.addEventListener("click", (() => this.onSectionToggle(e))), this.setInitialState(e)
         }))
     }
     constructHeaderMarkup(e, t) {
-        const n = e.querySelector(`.${this.sectionButtonClass}`),
-            i = e.querySelector(`.${this.sectionHeadingClass}`),
-            s = e.querySelector(`.${this.sectionSummaryClass}`);
-        if (!i) throw new ElementError({
+        const s = e.querySelector(`.${this.sectionButtonClass}`),
+            n = e.querySelector(`.${this.sectionHeadingClass}`),
+            i = e.querySelector(`.${this.sectionSummaryClass}`);
+        if (!n) throw new ElementError({
             componentName: "Accordion",
             identifier: `Section heading (\`.${this.sectionHeadingClass}\`)`
         });
-        if (!n) throw new ElementError({
+        if (!s) throw new ElementError({
             componentName: "Accordion",
             identifier: `Section button placeholder (\`<span class="${this.sectionButtonClass}">\`)`
         });
         const o = document.createElement("button");
         o.setAttribute("type", "button"), o.setAttribute("aria-controls", `${this.$module.id}-content-${t+1}`);
-        for (const d of Array.from(n.attributes)) "id" !== d.nodeName && o.setAttribute(d.nodeName, `${d.nodeValue}`);
+        for (const d of Array.from(s.attributes)) "id" !== d.nodeName && o.setAttribute(d.nodeName, `${d.nodeValue}`);
         const r = document.createElement("span");
-        r.classList.add(this.sectionHeadingTextClass), r.id = n.id;
+        r.classList.add(this.sectionHeadingTextClass), r.id = s.id;
         const a = document.createElement("span");
-        a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), a.innerHTML = n.innerHTML;
+        a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), a.innerHTML = s.innerHTML;
         const l = document.createElement("span");
         l.classList.add(this.sectionShowHideToggleClass), l.setAttribute("data-nosnippet", "");
         const c = document.createElement("span");
         c.classList.add(this.sectionShowHideToggleFocusClass), l.appendChild(c);
         const h = document.createElement("span"),
             u = document.createElement("span");
-        if (u.classList.add(this.upChevronIconClass), c.appendChild(u), h.classList.add(this.sectionShowHideTextClass), c.appendChild(h), o.appendChild(r), o.appendChild(this.getButtonPunctuationEl()), null != s && s.parentNode) {
+        if (u.classList.add(this.upChevronIconClass), c.appendChild(u), h.classList.add(this.sectionShowHideTextClass), c.appendChild(h), o.appendChild(r), o.appendChild(this.getButtonPunctuationEl()), null != i && i.parentNode) {
             const e = document.createElement("span"),
                 t = document.createElement("span");
             t.classList.add(this.sectionSummaryFocusClass), e.appendChild(t);
-            for (const n of Array.from(s.attributes)) e.setAttribute(n.nodeName, `${n.nodeValue}`);
-            t.innerHTML = s.innerHTML, s.parentNode.replaceChild(e, s), o.appendChild(e), o.appendChild(this.getButtonPunctuationEl())
+            for (const s of Array.from(i.attributes)) e.setAttribute(s.nodeName, `${s.nodeValue}`);
+            t.innerHTML = i.innerHTML, i.parentNode.replaceChild(e, i), o.appendChild(e), o.appendChild(this.getButtonPunctuationEl())
         }
-        o.appendChild(l), i.removeChild(n), i.appendChild(o)
+        o.appendChild(l), n.removeChild(s), n.appendChild(o)
     }
     onBeforeMatch(e) {
         const t = e.target;
         if (!(t instanceof Element)) return;
-        const n = t.closest(`.${this.sectionClass}`);
-        n && this.setExpanded(!0, n)
+        const s = t.closest(`.${this.sectionClass}`);
+        s && this.setExpanded(!0, s)
     }
     onSectionToggle(e) {
         const t = this.isExpanded(e);
@@ -289,24 +289,24 @@ class Accordion extends GOVUKFrontendComponent {
         })), this.updateShowAllButton(e)
     }
     setExpanded(e, t) {
-        const n = t.querySelector(`.${this.upChevronIconClass}`),
-            i = t.querySelector(`.${this.sectionShowHideTextClass}`),
-            s = t.querySelector(`.${this.sectionButtonClass}`),
+        const s = t.querySelector(`.${this.upChevronIconClass}`),
+            n = t.querySelector(`.${this.sectionShowHideTextClass}`),
+            i = t.querySelector(`.${this.sectionButtonClass}`),
             o = t.querySelector(`.${this.sectionContentClass}`);
         if (!o) throw new ElementError({
             componentName: "Accordion",
             identifier: `Section content (\`<div class="${this.sectionContentClass}">\`)`
         });
-        if (!n || !i || !s) return;
+        if (!s || !n || !i) return;
         const r = e ? this.i18n.t("hideSection") : this.i18n.t("showSection");
-        i.textContent = r, s.setAttribute("aria-expanded", `${e}`);
+        n.textContent = r, i.setAttribute("aria-expanded", `${e}`);
         const a = [],
             l = t.querySelector(`.${this.sectionHeadingTextClass}`);
         l && a.push(`${l.textContent}`.trim());
         const c = t.querySelector(`.${this.sectionSummaryClass}`);
         c && a.push(`${c.textContent}`.trim());
         const h = e ? this.i18n.t("hideSectionAriaLabel") : this.i18n.t("showSectionAriaLabel");
-        a.push(h), s.setAttribute("aria-label", a.join(" , ")), e ? (o.removeAttribute("hidden"), t.classList.add(this.sectionExpandedClass), n.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), t.classList.remove(this.sectionExpandedClass), n.classList.add(this.downChevronIconClass));
+        a.push(h), i.setAttribute("aria-label", a.join(" , ")), e ? (o.removeAttribute("hidden"), t.classList.add(this.sectionExpandedClass), s.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), t.classList.remove(this.sectionExpandedClass), s.classList.add(this.downChevronIconClass));
         const u = this.checkIfAllSectionsOpen();
         this.updateShowAllButton(u)
     }
@@ -324,8 +324,8 @@ class Accordion extends GOVUKFrontendComponent {
             const t = e.querySelector(`.${this.sectionButtonClass}`);
             if (t) {
                 const e = t.getAttribute("aria-controls"),
-                    n = t.getAttribute("aria-expanded");
-                e && n && window.sessionStorage.setItem(e, n)
+                    s = t.getAttribute("aria-expanded");
+                e && s && window.sessionStorage.setItem(e, s)
             }
         }
     }
@@ -333,9 +333,9 @@ class Accordion extends GOVUKFrontendComponent {
         if (this.browserSupportsSessionStorage && this.config.rememberExpanded) {
             const t = e.querySelector(`.${this.sectionButtonClass}`);
             if (t) {
-                const n = t.getAttribute("aria-controls"),
-                    i = n ? window.sessionStorage.getItem(n) : null;
-                null !== i && this.setExpanded("true" === i, e)
+                const s = t.getAttribute("aria-controls"),
+                    n = s ? window.sessionStorage.getItem(s) : null;
+                null !== n && this.setExpanded("true" === n, e)
             }
         }
     }
@@ -370,7 +370,7 @@ const e = {
         let t;
         try {
             return window.sessionStorage.setItem(e, e), t = window.sessionStorage.getItem(e) === e.toString(), window.sessionStorage.removeItem(e), t
-        } catch (n) {
+        } catch (s) {
             return !1
         }
     }
@@ -396,8 +396,8 @@ class Button extends GOVUKFrontendComponent {
 }
 
 function closestAttributeValue(e, t) {
-    const n = e.closest(`[${t}]`);
-    return n ? n.getAttribute(t) : null
+    const s = e.closest(`[${t}]`);
+    return s ? s.getAttribute(t) : null
 }
 Button.moduleName = "govuk-button", Button.defaults = Object.freeze({
     preventDoubleClick: !1
@@ -410,16 +410,16 @@ Button.moduleName = "govuk-button", Button.defaults = Object.freeze({
 });
 class CharacterCount extends GOVUKFrontendComponent {
     constructor(e, t = {}) {
-        var n, i;
+        var s, n;
         if (super(), this.$module = void 0, this.$textarea = void 0, this.$visibleCountMessage = void 0, this.$screenReaderCountMessage = void 0, this.lastInputTimestamp = null, this.lastInputValue = "", this.valueChecker = null, this.config = void 0, this.i18n = void 0, this.maxLength = void 0, !(e instanceof HTMLElement)) throw new ElementError({
             componentName: "Character count",
             element: e,
             identifier: "Root element (`$module`)"
         });
-        const s = e.querySelector(".govuk-js-character-count");
-        if (!(s instanceof HTMLTextAreaElement || s instanceof HTMLInputElement)) throw new ElementError({
+        const i = e.querySelector(".govuk-js-character-count");
+        if (!(i instanceof HTMLTextAreaElement || i instanceof HTMLInputElement)) throw new ElementError({
             componentName: "Character count",
-            element: s,
+            element: i,
             expectedType: "HTMLTextareaElement or HTMLInputElement",
             identifier: "Form field (`.govuk-js-character-count`)"
         });
@@ -430,24 +430,24 @@ class CharacterCount extends GOVUKFrontendComponent {
             maxwords: void 0
         }), this.config = mergeConfigs(CharacterCount.defaults, t, r, o);
         const a = function(e, t) {
-            const n = [];
-            for (const [i, s] of Object.entries(e)) {
+            const s = [];
+            for (const [n, i] of Object.entries(e)) {
                 const e = [];
-                if (Array.isArray(s)) {
+                if (Array.isArray(i)) {
                     for (const {
-                            required: n,
-                            errorMessage: i
+                            required: s,
+                            errorMessage: n
                         }
-                        of s) n.every((e => !!t[e])) || e.push(i);
-                    "anyOf" !== i || s.length - e.length >= 1 || n.push(...e)
+                        of i) s.every((e => !!t[e])) || e.push(n);
+                    "anyOf" !== n || i.length - e.length >= 1 || s.push(...e)
                 }
             }
-            return n
+            return s
         }(CharacterCount.schema, this.config);
         if (a[0]) throw new ConfigError(`Character count: ${a[0]}`);
         this.i18n = new I18n(this.config.i18n, {
             locale: closestAttributeValue(e, "lang")
-        }), this.maxLength = null != (n = null != (i = this.config.maxwords) ? i : this.config.maxlength) ? n : 1 / 0, this.$module = e, this.$textarea = s;
+        }), this.maxLength = null != (s = null != (n = this.config.maxwords) ? n : this.config.maxlength) ? s : 1 / 0, this.$module = e, this.$textarea = i;
         const l = `${this.$textarea.id}-info`,
             c = document.getElementById(l);
         if (!c) throw new ElementError({
@@ -504,8 +504,8 @@ class CharacterCount extends GOVUKFrontendComponent {
     }
     formatCountMessage(e, t) {
         if (0 === e) return this.i18n.t(`${t}AtLimit`);
-        const n = e < 0 ? "OverLimit" : "UnderLimit";
-        return this.i18n.t(`${t}${n}`, {
+        const s = e < 0 ? "OverLimit" : "UnderLimit";
+        return this.i18n.t(`${t}${s}`, {
             count: Math.abs(e)
         })
     }
@@ -592,10 +592,10 @@ class Checkboxes extends GOVUKFrontendComponent {
     syncConditionalRevealWithInputState(e) {
         const t = e.getAttribute("aria-controls");
         if (!t) return;
-        const n = document.getElementById(t);
-        if (n && n.classList.contains("govuk-checkboxes__conditional")) {
+        const s = document.getElementById(t);
+        if (s && s.classList.contains("govuk-checkboxes__conditional")) {
             const t = e.checked;
-            e.setAttribute("aria-expanded", t.toString()), n.classList.toggle("govuk-checkboxes__conditional--hidden", !t)
+            e.setAttribute("aria-expanded", t.toString()), s.classList.toggle("govuk-checkboxes__conditional--hidden", !t)
         }
     }
     unCheckAllInputsExcept(e) {
@@ -633,25 +633,25 @@ class ErrorSummary extends GOVUKFrontendComponent {
         if (!(e instanceof HTMLAnchorElement)) return !1;
         const t = getFragmentFromUrl(e.href);
         if (!t) return !1;
-        const n = document.getElementById(t);
-        if (!n) return !1;
-        const i = this.getAssociatedLegendOrLabel(n);
-        return !!i && (i.scrollIntoView(), n.focus({
+        const s = document.getElementById(t);
+        if (!s) return !1;
+        const n = this.getAssociatedLegendOrLabel(s);
+        return !!n && (n.scrollIntoView(), s.focus({
             preventScroll: !0
         }), !0)
     }
     getAssociatedLegendOrLabel(e) {
         var t;
-        const n = e.closest("fieldset");
-        if (n) {
-            const t = n.getElementsByTagName("legend");
+        const s = e.closest("fieldset");
+        if (s) {
+            const t = s.getElementsByTagName("legend");
             if (t.length) {
-                const n = t[0];
-                if (e instanceof HTMLInputElement && ("checkbox" === e.type || "radio" === e.type)) return n;
-                const i = n.getBoundingClientRect().top,
-                    s = e.getBoundingClientRect();
-                if (s.height && window.innerHeight) {
-                    if (s.top + s.height - i < window.innerHeight / 2) return n
+                const s = t[0];
+                if (e instanceof HTMLInputElement && ("checkbox" === e.type || "radio" === e.type)) return s;
+                const n = s.getBoundingClientRect().top,
+                    i = e.getBoundingClientRect();
+                if (i.height && window.innerHeight) {
+                    if (i.top + i.height - n < window.innerHeight / 2) return s
                 }
             }
         }
@@ -674,16 +674,16 @@ class ExitThisPage extends GOVUKFrontendComponent {
             element: e,
             identifier: "Root element (`$module`)"
         });
-        const n = e.querySelector(".govuk-exit-this-page__button");
-        if (!(n instanceof HTMLAnchorElement)) throw new ElementError({
+        const s = e.querySelector(".govuk-exit-this-page__button");
+        if (!(s instanceof HTMLAnchorElement)) throw new ElementError({
             componentName: "Exit this page",
-            element: n,
+            element: s,
             expectedType: "HTMLAnchorElement",
             identifier: "Button (`.govuk-exit-this-page__button`)"
         });
-        this.config = mergeConfigs(ExitThisPage.defaults, t, normaliseDataset(ExitThisPage, e.dataset)), this.i18n = new I18n(this.config.i18n), this.$module = e, this.$button = n;
-        const i = document.querySelector(".govuk-js-exit-this-page-skiplink");
-        i instanceof HTMLAnchorElement && (this.$skiplinkButton = i), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
+        this.config = mergeConfigs(ExitThisPage.defaults, t, normaliseDataset(ExitThisPage, e.dataset)), this.i18n = new I18n(this.config.i18n), this.$module = e, this.$button = s;
+        const n = document.querySelector(".govuk-js-exit-this-page-skiplink");
+        n instanceof HTMLAnchorElement && (this.$skiplinkButton = n), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
     }
     initUpdateSpan() {
         this.$updateSpan = document.createElement("span"), this.$updateSpan.setAttribute("role", "status"), this.$updateSpan.className = "govuk-visually-hidden", this.$module.appendChild(this.$updateSpan)
@@ -754,18 +754,18 @@ class Header extends GOVUKFrontendComponent {
         this.$module = e;
         const t = e.querySelector(".govuk-js-header-toggle");
         if (!t) return this;
-        const n = t.getAttribute("aria-controls");
-        if (!n) throw new ElementError({
+        const s = t.getAttribute("aria-controls");
+        if (!s) throw new ElementError({
             componentName: "Header",
             identifier: 'Navigation button (`<button class="govuk-js-header-toggle">`) attribute (`aria-controls`)'
         });
-        const i = document.getElementById(n);
-        if (!i) throw new ElementError({
+        const n = document.getElementById(s);
+        if (!n) throw new ElementError({
             componentName: "Header",
-            element: i,
-            identifier: `Navigation (\`<ul id="${n}">\`)`
+            element: n,
+            identifier: `Navigation (\`<ul id="${s}">\`)`
         });
-        this.$menu = i, this.$menuButton = t, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+        this.$menu = n, this.$menuButton = t, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
     }
     setupResponsiveChecks() {
         const e = getBreakpoint("desktop");
@@ -802,6 +802,71 @@ NotificationBanner.moduleName = "govuk-notification-banner", NotificationBanner.
         }
     }
 });
+class PasswordInput extends GOVUKFrontendComponent {
+    constructor(e, t = {}) {
+        if (super(), this.$module = void 0, this.config = void 0, this.i18n = void 0, this.$input = void 0, this.$showHideButton = void 0, this.$screenReaderStatusMessage = void 0, !(e instanceof HTMLElement)) throw new ElementError({
+            componentName: "Password input",
+            element: e,
+            identifier: "Root element (`$module`)"
+        });
+        const s = e.querySelector(".govuk-js-password-input-input");
+        if (!(s instanceof HTMLInputElement)) throw new ElementError({
+            componentName: "Password input",
+            element: s,
+            expectedType: "HTMLInputElement",
+            identifier: "Form field (`.govuk-js-password-input-input`)"
+        });
+        if ("password" !== s.type) throw new ElementError("Password input: Form field (`.govuk-js-password-input-input`) must be of type `password`.");
+        const n = e.querySelector(".govuk-js-password-input-toggle");
+        if (!(n instanceof HTMLButtonElement)) throw new ElementError({
+            componentName: "Password input",
+            element: n,
+            expectedType: "HTMLButtonElement",
+            identifier: "Button (`.govuk-js-password-input-toggle`)"
+        });
+        if ("button" !== n.type) throw new ElementError("Password input: Button (`.govuk-js-password-input-toggle`) must be of type `button`.");
+        this.$module = e, this.$input = s, this.$showHideButton = n, this.config = mergeConfigs(PasswordInput.defaults, t, normaliseDataset(PasswordInput, e.dataset)), this.i18n = new I18n(this.config.i18n, {
+            locale: closestAttributeValue(e, "lang")
+        }), this.$showHideButton.removeAttribute("hidden");
+        const i = document.createElement("div");
+        i.className = "govuk-password-input__sr-status govuk-visually-hidden", i.setAttribute("aria-live", "polite"), this.$screenReaderStatusMessage = i, this.$input.insertAdjacentElement("afterend", i), this.$showHideButton.addEventListener("click", this.toggle.bind(this)), this.$input.form && this.$input.form.addEventListener("submit", (() => this.hide())), window.addEventListener("pageshow", (e => {
+            e.persisted && "password" !== this.$input.type && this.hide()
+        })), this.hide()
+    }
+    toggle(e) {
+        e.preventDefault(), "password" !== this.$input.type ? this.hide() : this.show()
+    }
+    show() {
+        this.setType("text")
+    }
+    hide() {
+        this.setType("password")
+    }
+    setType(e) {
+        if (e === this.$input.type) return;
+        this.$input.setAttribute("type", e);
+        const t = "password" === e,
+            s = t ? "show" : "hide",
+            n = t ? "passwordHidden" : "passwordShown";
+        this.$showHideButton.innerHTML = this.i18n.t(`${s}Password`), this.$showHideButton.setAttribute("aria-label", this.i18n.t(`${s}PasswordAriaLabel`)), this.$screenReaderStatusMessage.innerText = this.i18n.t(`${n}Announcement`)
+    }
+}
+PasswordInput.moduleName = "govuk-password-input", PasswordInput.defaults = Object.freeze({
+    i18n: {
+        showPassword: "Show",
+        hidePassword: "Hide",
+        showPasswordAriaLabel: "Show password",
+        hidePasswordAriaLabel: "Hide password",
+        passwordShownAnnouncement: "Your password is visible",
+        passwordHiddenAnnouncement: "Your password is hidden"
+    }
+}), PasswordInput.schema = Object.freeze({
+    properties: {
+        i18n: {
+            type: "object"
+        }
+    }
+});
 class Radios extends GOVUKFrontendComponent {
     constructor(e) {
         if (super(), this.$module = void 0, this.$inputs = void 0, !(e instanceof HTMLElement)) throw new ElementError({
@@ -831,21 +896,21 @@ class Radios extends GOVUKFrontendComponent {
     syncConditionalRevealWithInputState(e) {
         const t = e.getAttribute("aria-controls");
         if (!t) return;
-        const n = document.getElementById(t);
-        if (null != n && n.classList.contains("govuk-radios__conditional")) {
+        const s = document.getElementById(t);
+        if (null != s && s.classList.contains("govuk-radios__conditional")) {
             const t = e.checked;
-            e.setAttribute("aria-expanded", t.toString()), n.classList.toggle("govuk-radios__conditional--hidden", !t)
+            e.setAttribute("aria-expanded", t.toString()), s.classList.toggle("govuk-radios__conditional--hidden", !t)
         }
     }
     handleClick(e) {
         const t = e.target;
         if (!(t instanceof HTMLInputElement) || "radio" !== t.type) return;
-        const n = document.querySelectorAll('input[type="radio"][aria-controls]'),
-            i = t.form,
-            s = t.name;
-        n.forEach((e => {
-            const t = e.form === i;
-            e.name === s && t && this.syncConditionalRevealWithInputState(e)
+        const s = document.querySelectorAll('input[type="radio"][aria-controls]'),
+            n = t.form,
+            i = t.name;
+        s.forEach((e => {
+            const t = e.form === n;
+            e.name === i && t && this.syncConditionalRevealWithInputState(e)
         }))
     }
 }
@@ -860,17 +925,17 @@ class SkipLink extends GOVUKFrontendComponent {
             identifier: "Root element (`$module`)"
         });
         this.$module = e;
-        const n = this.$module.hash,
-            i = null != (t = this.$module.getAttribute("href")) ? t : "";
-        let s;
+        const s = this.$module.hash,
+            n = null != (t = this.$module.getAttribute("href")) ? t : "";
+        let i;
         try {
-            s = new window.URL(this.$module.href)
+            i = new window.URL(this.$module.href)
         } catch (a) {
-            throw new ElementError(`Skip link: Target link (\`href="${i}"\`) is invalid`)
+            throw new ElementError(`Skip link: Target link (\`href="${n}"\`) is invalid`)
         }
-        if (s.origin !== window.location.origin || s.pathname !== window.location.pathname) return;
-        const o = getFragmentFromUrl(n);
-        if (!o) throw new ElementError(`Skip link: Target link (\`href="${i}"\`) has no hash fragment`);
+        if (i.origin !== window.location.origin || i.pathname !== window.location.pathname) return;
+        const o = getFragmentFromUrl(s);
+        if (!o) throw new ElementError(`Skip link: Target link (\`href="${n}"\`) has no hash fragment`);
         const r = document.getElementById(o);
         if (!r) throw new ElementError({
             componentName: "Skip link",
@@ -901,17 +966,17 @@ class Tabs extends GOVUKFrontendComponent {
             identifier: 'Links (`<a class="govuk-tabs__tab">`)'
         });
         this.$module = e, this.$tabs = t, this.boundTabClick = this.onTabClick.bind(this), this.boundTabKeydown = this.onTabKeydown.bind(this), this.boundOnHashChange = this.onHashChange.bind(this);
-        const n = this.$module.querySelector(".govuk-tabs__list"),
-            i = this.$module.querySelectorAll("li.govuk-tabs__list-item");
-        if (!n) throw new ElementError({
+        const s = this.$module.querySelector(".govuk-tabs__list"),
+            n = this.$module.querySelectorAll("li.govuk-tabs__list-item");
+        if (!s) throw new ElementError({
             componentName: "Tabs",
             identifier: 'List (`<ul class="govuk-tabs__list">`)'
         });
-        if (!i.length) throw new ElementError({
+        if (!n.length) throw new ElementError({
             componentName: "Tabs",
             identifier: 'List items (`<li class="govuk-tabs__list-item">`)'
         });
-        this.$tabList = n, this.$tabListItems = i, this.setupResponsiveChecks()
+        this.$tabList = s, this.$tabListItems = n, this.setupResponsiveChecks()
     }
     setupResponsiveChecks() {
         const e = getBreakpoint("tablet");
@@ -947,8 +1012,8 @@ class Tabs extends GOVUKFrontendComponent {
             t = this.getTab(e);
         if (!t) return;
         if (this.changingHash) return void(this.changingHash = !1);
-        const n = this.getCurrentTab();
-        n && (this.hideTab(n), this.showTab(t), t.focus())
+        const s = this.getCurrentTab();
+        s && (this.hideTab(s), this.showTab(t), t.focus())
     }
     hideTab(e) {
         this.unhighlightTab(e), this.hidePanel(e)
@@ -963,8 +1028,8 @@ class Tabs extends GOVUKFrontendComponent {
         const t = getFragmentFromUrl(e.href);
         if (!t) return;
         e.setAttribute("id", `tab_${t}`), e.setAttribute("role", "tab"), e.setAttribute("aria-controls", t), e.setAttribute("aria-selected", "false"), e.setAttribute("tabindex", "-1");
-        const n = this.getPanel(e);
-        n && (n.setAttribute("role", "tabpanel"), n.setAttribute("aria-labelledby", e.id), n.classList.add(this.jsHiddenClass))
+        const s = this.getPanel(e);
+        s && (s.setAttribute("role", "tabpanel"), s.setAttribute("aria-labelledby", e.id), s.classList.add(this.jsHiddenClass))
     }
     unsetAttributes(e) {
         e.removeAttribute("id"), e.removeAttribute("role"), e.removeAttribute("aria-controls"), e.removeAttribute("aria-selected"), e.removeAttribute("tabindex");
@@ -973,14 +1038,14 @@ class Tabs extends GOVUKFrontendComponent {
     }
     onTabClick(e) {
         const t = this.getCurrentTab(),
-            n = e.currentTarget;
-        t && n instanceof HTMLAnchorElement && (e.preventDefault(), this.hideTab(t), this.showTab(n), this.createHistoryEntry(n))
+            s = e.currentTarget;
+        t && s instanceof HTMLAnchorElement && (e.preventDefault(), this.hideTab(t), this.showTab(s), this.createHistoryEntry(s))
     }
     createHistoryEntry(e) {
         const t = this.getPanel(e);
         if (!t) return;
-        const n = t.id;
-        t.id = "", this.changingHash = !0, window.location.hash = n, t.id = n
+        const s = t.id;
+        t.id = "", this.changingHash = !0, window.location.hash = s, t.id = s
     }
     onTabKeydown(e) {
         switch (e.key) {
@@ -1002,16 +1067,16 @@ class Tabs extends GOVUKFrontendComponent {
         if (null == e || !e.parentElement) return;
         const t = e.parentElement.nextElementSibling;
         if (!t) return;
-        const n = t.querySelector("a.govuk-tabs__tab");
-        n && (this.hideTab(e), this.showTab(n), n.focus(), this.createHistoryEntry(n))
+        const s = t.querySelector("a.govuk-tabs__tab");
+        s && (this.hideTab(e), this.showTab(s), s.focus(), this.createHistoryEntry(s))
     }
     activatePreviousTab() {
         const e = this.getCurrentTab();
         if (null == e || !e.parentElement) return;
         const t = e.parentElement.previousElementSibling;
         if (!t) return;
-        const n = t.querySelector("a.govuk-tabs__tab");
-        n && (this.hideTab(e), this.showTab(n), n.focus(), this.createHistoryEntry(n))
+        const s = t.querySelector("a.govuk-tabs__tab");
+        s && (this.hideTab(e), this.showTab(s), s.focus(), this.createHistoryEntry(s))
     }
     getPanel(e) {
         const t = getFragmentFromUrl(e.href);
@@ -1039,7 +1104,7 @@ class Tabs extends GOVUKFrontendComponent {
 function initAll(e) {
     var t;
     if (e = void 0 !== e ? e : {}, !isSupported()) return void console.log(new SupportError);
-    const n = [
+    const s = [
             [Accordion, e.accordion],
             [Button, e.button],
             [CharacterCount, e.characterCount],
@@ -1048,17 +1113,18 @@ function initAll(e) {
             [ExitThisPage, e.exitThisPage],
             [Header],
             [NotificationBanner, e.notificationBanner],
+            [PasswordInput, e.passwordInput],
             [Radios],
             [SkipLink],
             [Tabs]
         ],
-        i = null != (t = e.scope) ? t : document;
-    n.forEach((([e, t]) => {
-        i.querySelectorAll(`[data-module="${e.moduleName}"]`).forEach((n => {
+        n = null != (t = e.scope) ? t : document;
+    s.forEach((([e, t]) => {
+        n.querySelectorAll(`[data-module="${e.moduleName}"]`).forEach((s => {
             try {
-                "defaults" in e ? new e(n, t) : new e(n)
-            } catch (i) {
-                console.log(i)
+                "defaults" in e ? new e(s, t) : new e(s)
+            } catch (n) {
+                console.log(n)
             }
         }))
     }))
@@ -1073,6 +1139,7 @@ export {
     ExitThisPage,
     Header,
     NotificationBanner,
+    PasswordInput,
     Radios,
     SkipLink,
     Tabs,

Action run for 2b5d976

Copy link

github-actions bot commented Dec 19, 2023

Stylesheets changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
index fa1f74dc6..2c37191c5 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
@@ -4643,6 +4643,37 @@ only screen and (min-resolution:2dppx) {
     margin-bottom: 0
 }
 
+@media (min-width:20em) {
+    .govuk-password-input__wrapper {
+        flex-direction: row;
+        align-items: flex-start
+    }
+}
+
+.govuk-password-input__input::-ms-reveal {
+    display: none
+}
+
+.govuk-password-input__toggle {
+    margin-top: 5px;
+    margin-bottom: 0
+}
+
+.govuk-password-input__toggle[hidden] {
+    display: none
+}
+
+@media (min-width:20em) {
+    .govuk-password-input__toggle {
+        width: auto;
+        flex-grow: 1;
+        flex-shrink: 0;
+        flex-basis: 5em;
+        margin-top: 0;
+        margin-left: 5px
+    }
+}
+
 .govuk-tag {
     font-family: GDS Transport, arial, sans-serif;
     -webkit-font-smoothing: antialiased;

Action run for 2b5d976

Copy link

github-actions bot commented Dec 19, 2023

Other changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.js b/packages/govuk-frontend/dist/govuk/all.bundle.js
index a83698cb6..1f067d59e 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -1758,6 +1758,160 @@
     }
   });
 
+  /**
+   * Password input component
+   *
+   * @preserve
+   */
+  class PasswordInput extends GOVUKFrontendComponent {
+    /**
+     * @param {Element | null} $module - HTML element to use for password input
+     * @param {PasswordInputConfig} [config] - Password input config
+     */
+    constructor($module, config = {}) {
+      super();
+      this.$module = void 0;
+      this.config = void 0;
+      this.i18n = void 0;
+      this.$input = void 0;
+      this.$showHideButton = void 0;
+      this.$screenReaderStatusMessage = void 0;
+      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, {
+        locale: closestAttributeValue($module, 'lang')
+      });
+      this.$showHideButton.removeAttribute('hidden');
+      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);
+      this.$showHideButton.addEventListener('click', this.toggle.bind(this));
+      if (this.$input.form) {
+        this.$input.form.addEventListener('submit', () => this.hide());
+      }
+      window.addEventListener('pageshow', event => {
+        if (event.persisted && this.$input.type !== 'password') {
+          this.hide();
+        }
+      });
+      this.hide();
+    }
+    toggle(event) {
+      event.preventDefault();
+      if (this.$input.type === 'password') {
+        this.show();
+        return;
+      }
+      this.hide();
+    }
+    show() {
+      this.setType('text');
+    }
+    hide() {
+      this.setType('password');
+    }
+    setType(type) {
+      if (type === this.$input.type) {
+        return;
+      }
+      this.$input.setAttribute('type', type);
+      const isHidden = type === 'password';
+      const prefixButton = isHidden ? 'show' : 'hide';
+      const prefixStatus = isHidden ? 'passwordHidden' : 'passwordShown';
+      this.$showHideButton.innerHTML = this.i18n.t(`${prefixButton}Password`);
+      this.$showHideButton.setAttribute('aria-label', this.i18n.t(`${prefixButton}PasswordAriaLabel`));
+      this.$screenReaderStatusMessage.innerText = this.i18n.t(`${prefixStatus}Announcement`);
+    }
+  }
+
+  /**
+   * 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
+   */
+  PasswordInput.moduleName = 'govuk-password-input';
+  PasswordInput.defaults = Object.freeze({
+    i18n: {
+      showPassword: 'Show',
+      hidePassword: 'Hide',
+      showPasswordAriaLabel: 'Show password',
+      hidePasswordAriaLabel: 'Hide password',
+      passwordShownAnnouncement: 'Your password is visible',
+      passwordHiddenAnnouncement: 'Your password is hidden'
+    }
+  });
+  PasswordInput.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
+
   /**
    * Radios component
    *
@@ -2215,7 +2369,7 @@
       console.log(new SupportError());
       return;
     }
-    const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [Radios], [SkipLink], [Tabs]];
+    const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [SkipLink], [Tabs]];
     const $scope = (_config$scope = config.scope) != null ? _config$scope : document;
     components.forEach(([Component, config]) => {
       const $elements = $scope.querySelectorAll(`[data-module="${Component.moduleName}"]`);
@@ -2239,6 +2393,7 @@
    * @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
    */
 
   /**
@@ -2253,6 +2408,7 @@
    * @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
    */
 
   /**
@@ -2269,6 +2425,7 @@
   exports.ExitThisPage = ExitThisPage;
   exports.Header = Header;
   exports.NotificationBanner = NotificationBanner;
+  exports.PasswordInput = PasswordInput;
   exports.Radios = Radios;
   exports.SkipLink = SkipLink;
   exports.Tabs = Tabs;
diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.mjs b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
index 7662df517..838129163 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -1752,6 +1752,160 @@ NotificationBanner.schema = Object.freeze({
   }
 });
 
+/**
+ * Password input component
+ *
+ * @preserve
+ */
+class PasswordInput extends GOVUKFrontendComponent {
+  /**
+   * @param {Element | null} $module - HTML element to use for password input
+   * @param {PasswordInputConfig} [config] - Password input config
+   */
+  constructor($module, config = {}) {
+    super();
+    this.$module = void 0;
+    this.config = void 0;
+    this.i18n = void 0;
+    this.$input = void 0;
+    this.$showHideButton = void 0;
+    this.$screenReaderStatusMessage = void 0;
+    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, {
+      locale: closestAttributeValue($module, 'lang')
+    });
+    this.$showHideButton.removeAttribute('hidden');
+    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);
+    this.$showHideButton.addEventListener('click', this.toggle.bind(this));
+    if (this.$input.form) {
+      this.$input.form.addEventListener('submit', () => this.hide());
+    }
+    window.addEventListener('pageshow', event => {
+      if (event.persisted && this.$input.type !== 'password') {
+        this.hide();
+      }
+    });
+    this.hide();
+  }
+  toggle(event) {
+    event.preventDefault();
+    if (this.$input.type === 'password') {
+      this.show();
+      return;
+    }
+    this.hide();
+  }
+  show() {
+    this.setType('text');
+  }
+  hide() {
+    this.setType('password');
+  }
+  setType(type) {
+    if (type === this.$input.type) {
+      return;
+    }
+    this.$input.setAttribute('type', type);
+    const isHidden = type === 'password';
+    const prefixButton = isHidden ? 'show' : 'hide';
+    const prefixStatus = isHidden ? 'passwordHidden' : 'passwordShown';
+    this.$showHideButton.innerHTML = this.i18n.t(`${prefixButton}Password`);
+    this.$showHideButton.setAttribute('aria-label', this.i18n.t(`${prefixButton}PasswordAriaLabel`));
+    this.$screenReaderStatusMessage.innerText = this.i18n.t(`${prefixStatus}Announcement`);
+  }
+}
+
+/**
+ * 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
+ */
+PasswordInput.moduleName = 'govuk-password-input';
+PasswordInput.defaults = Object.freeze({
+  i18n: {
+    showPassword: 'Show',
+    hidePassword: 'Hide',
+    showPasswordAriaLabel: 'Show password',
+    hidePasswordAriaLabel: 'Hide password',
+    passwordShownAnnouncement: 'Your password is visible',
+    passwordHiddenAnnouncement: 'Your password is hidden'
+  }
+});
+PasswordInput.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+
 /**
  * Radios component
  *
@@ -2209,7 +2363,7 @@ function initAll(config) {
     console.log(new SupportError());
     return;
   }
-  const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [Radios], [SkipLink], [Tabs]];
+  const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [SkipLink], [Tabs]];
   const $scope = (_config$scope = config.scope) != null ? _config$scope : document;
   components.forEach(([Component, config]) => {
     const $elements = $scope.querySelectorAll(`[data-module="${Component.moduleName}"]`);
@@ -2233,6 +2387,7 @@ function initAll(config) {
  * @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
  */
 
 /**
@@ -2247,6 +2402,7 @@ function initAll(config) {
  * @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
  */
 
 /**
@@ -2255,5 +2411,5 @@ function initAll(config) {
  * @typedef {keyof Config} ConfigKey
  */
 
-export { Accordion, Button, CharacterCount, Checkboxes, ErrorSummary, ExitThisPage, Header, NotificationBanner, Radios, SkipLink, Tabs, initAll, version };
+export { Accordion, Button, CharacterCount, Checkboxes, ErrorSummary, ExitThisPage, Header, NotificationBanner, PasswordInput, Radios, SkipLink, Tabs, initAll, version };
 //# sourceMappingURL=all.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/all.mjs b/packages/govuk-frontend/dist/govuk/all.mjs
index ba303fbd4..02cb75e14 100644
--- a/packages/govuk-frontend/dist/govuk/all.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.mjs
@@ -8,6 +8,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';
@@ -28,7 +29,7 @@ function initAll(config) {
     console.log(new SupportError());
     return;
   }
-  const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [Radios], [SkipLink], [Tabs]];
+  const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [SkipLink], [Tabs]];
   const $scope = (_config$scope = config.scope) != null ? _config$scope : document;
   components.forEach(([Component, config]) => {
     const $elements = $scope.querySelectorAll(`[data-module="${Component.moduleName}"]`);
@@ -52,6 +53,7 @@ function initAll(config) {
  * @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
  */
 
 /**
@@ -66,6 +68,7 @@ function initAll(config) {
  * @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
  */
 
 /**
@@ -74,5 +77,5 @@ function initAll(config) {
  * @typedef {keyof Config} ConfigKey
  */
 
-export { Accordion, Button, CharacterCount, Checkboxes, ErrorSummary, ExitThisPage, Header, NotificationBanner, Radios, SkipLink, Tabs, initAll };
+export { Accordion, Button, CharacterCount, Checkboxes, ErrorSummary, ExitThisPage, Header, NotificationBanner, PasswordInput, Radios, SkipLink, Tabs, initAll };
 //# sourceMappingURL=all.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/_all.scss b/packages/govuk-frontend/dist/govuk/components/_all.scss
index 5a5425470..e0f702951 100644
--- a/packages/govuk-frontend/dist/govuk/components/_all.scss
+++ b/packages/govuk-frontend/dist/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/dist/govuk/components/input/fixtures.json b/packages/govuk-frontend/dist/govuk/components/input/fixtures.json
index b7dbefda6..b30288cf5 100644
--- a/packages/govuk-frontend/dist/govuk/components/input/fixtures.json
+++ b/packages/govuk-frontend/dist/govuk/components/input/fixtures.json
@@ -274,6 +274,22 @@
             "previewLayoutModifiers": [],
             "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"input-with-spellcheck-disabled\">\n    Spellcheck is disabled\n  </label>\n  <input class=\"govuk-input\" id=\"input-with-spellcheck-disabled\" name=\"spellcheck\" type=\"text\" spellcheck=\"false\">\n</div>"
         },
+        {
+            "name": "with autocapitalize turned off",
+            "options": {
+                "label": {
+                    "text": "Autocapitalize is turned off"
+                },
+                "id": "input-with-autocapitalize-off",
+                "name": "autocapitalize",
+                "type": "text",
+                "autocapitalize": "none"
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"input-with-autocapitalize-off\">\n    Autocapitalize is turned off\n  </label>\n  <input class=\"govuk-input\" id=\"input-with-autocapitalize-off\" name=\"autocapitalize\" type=\"text\" autocapitalize=\"none\">\n</div>"
+        },
         {
             "name": "with prefix",
             "options": {
@@ -719,6 +735,32 @@
             "description": "",
             "previewLayoutModifiers": [],
             "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"input-with-suffix\">\n    Weight, in kilograms\n  </label>\n  <div class=\"govuk-input__wrapper\">\n    <input class=\"govuk-input\" id=\"input-with-suffix\" name=\"weight\" type=\"text\">\n    <div class=\"govuk-input__suffix\" aria-hidden=\"true\" data-attribute=\"value\"><span>kg</span></div>\n  </div>\n</div>"
+        },
+        {
+            "name": "with customised input wrapper",
+            "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"
+                }
+            },
+            "hidden": true,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"input-with-customised-input-wrapper\">\n    Cost per item, in pounds\n  </label>\n  <div class=\"govuk-input__wrapper app-input-wrapper--custom-modifier\" data-attribute=\"value\">\n    <div class=\"govuk-input__prefix\" aria-hidden=\"true\">£</div>\n    <input class=\"govuk-input\" id=\"input-with-customised-input-wrapper\" name=\"cost\" type=\"text\">\n    <div class=\"govuk-input__suffix\" aria-hidden=\"true\">per item</div>\n  </div>\n</div>"
         }
     ]
 }
diff --git a/packages/govuk-frontend/dist/govuk/components/input/macro-options.json b/packages/govuk-frontend/dist/govuk/components/input/macro-options.json
index 1d7e6b2f1..c76a2c90f 100644
--- a/packages/govuk-frontend/dist/govuk/components/input/macro-options.json
+++ b/packages/govuk-frontend/dist/govuk/components/input/macro-options.json
@@ -210,6 +210,32 @@
         "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",
diff --git a/packages/govuk-frontend/dist/govuk/components/password-input/_index.scss b/packages/govuk-frontend/dist/govuk/components/password-input/_index.scss
new file mode 100644
index 000000000..0fa68d8b4
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/password-input/_index.scss
@@ -0,0 +1,55 @@
+@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);
+    }
+  }
+}
+
+/*# sourceMappingURL=_index.scss.map */
diff --git a/packages/govuk-frontend/dist/govuk/components/password-input/_password-input.scss b/packages/govuk-frontend/dist/govuk/components/password-input/_password-input.scss
new file mode 100644
index 000000000..189d8057b
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/password-input/_password-input.scss
@@ -0,0 +1,4 @@
+@import "../../base";
+@import "./index";
+
+/*# sourceMappingURL=_password-input.scss.map */
diff --git a/packages/govuk-frontend/dist/govuk/components/password-input/fixtures.json b/packages/govuk-frontend/dist/govuk/components/password-input/fixtures.json
new file mode 100644
index 000000000..5c14c2ad4
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/password-input/fixtures.json
@@ -0,0 +1,170 @@
+{
+    "component": "password-input",
+    "fixtures": [
+        {
+            "name": "default",
+            "options": {
+                "label": {
+                    "text": "Password"
+                },
+                "id": "password-input",
+                "name": "password"
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\">\n  <label class=\"govuk-label\" for=\"password-input\">\n    Password\n  </label>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input\" id=\"password-input\" name=\"password\" type=\"password\" spellcheck=\"false\" autocomplete=\"current-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"password-input\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "with hint text",
+            "options": {
+                "label": {
+                    "text": "Password"
+                },
+                "hint": {
+                    "text": "It probably has some letters, numbers and maybe even some symbols in it."
+                },
+                "id": "password-input-with-hint-text",
+                "name": "test-name-2"
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\">\n  <label class=\"govuk-label\" for=\"password-input-with-hint-text\">\n    Password\n  </label>\n  <div id=\"password-input-with-hint-text-hint\" class=\"govuk-hint\">\n    It probably has some letters, numbers and maybe even some symbols in it.\n  </div>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input\" id=\"password-input-with-hint-text\" name=\"test-name-2\" type=\"password\" spellcheck=\"false\" aria-describedby=\"password-input-with-hint-text-hint\" autocomplete=\"current-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"password-input-with-hint-text\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "with error message",
+            "options": {
+                "label": {
+                    "text": "Password"
+                },
+                "hint": {
+                    "text": "It probably has some letters, numbers and maybe even some symbols in it."
+                },
+                "id": "password-input-with-error-message",
+                "name": "test-name-3",
+                "errorMessage": {
+                    "text": "Error message goes here"
+                }
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-form-group--error govuk-password-input\" data-module=\"govuk-password-input\">\n  <label class=\"govuk-label\" for=\"password-input-with-error-message\">\n    Password\n  </label>\n  <div id=\"password-input-with-error-message-hint\" class=\"govuk-hint\">\n    It probably has some letters, numbers and maybe even some symbols in it.\n  </div>\n  <p id=\"password-input-with-error-message-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message goes here\n  </p>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input govuk-input--error\" id=\"password-input-with-error-message\" name=\"test-name-3\" type=\"password\" spellcheck=\"false\" aria-describedby=\"password-input-with-error-message-hint password-input-with-error-message-error\" autocomplete=\"current-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"password-input-with-error-message\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "with label as page heading",
+            "options": {
+                "label": {
+                    "text": "Password",
+                    "classes": "govuk-label--l",
+                    "isPageHeading": true
+                },
+                "id": "password-input-with-page-heading",
+                "name": "test-name"
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\">\n  <h1 class=\"govuk-label-wrapper\">\n    <label class=\"govuk-label govuk-label--l\" for=\"password-input-with-page-heading\">\n      Password\n    </label>\n  </h1>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input\" id=\"password-input-with-page-heading\" name=\"test-name\" type=\"password\" spellcheck=\"false\" autocomplete=\"current-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"password-input-with-page-heading\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "with new-password autocomplete",
+            "options": {
+                "label": {
+                    "text": "Password"
+                },
+                "autocomplete": "new-password",
+                "id": "password-input-new-password",
+                "name": "password"
+            },
+            "hidden": false,
+            "description": "Browsers and password managers should prompt to generate a password.",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\">\n  <label class=\"govuk-label\" for=\"password-input-new-password\">\n    Password\n  </label>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input\" id=\"password-input-new-password\" name=\"password\" type=\"password\" spellcheck=\"false\" autocomplete=\"new-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"password-input-new-password\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "with translations",
+            "options": {
+                "label": {
+                    "text": "Cyfrinair"
+                },
+                "id": "password-translated",
+                "name": "password-translated",
+                "showPasswordText": "Datguddia",
+                "hidePasswordText": "Cuddio",
+                "showPasswordAriaLabelText": "Datgelu cyfrinair",
+                "hidePasswordAriaLabelText": "Cuddio cyfrinair",
+                "passwordShownAnnouncementText": "Mae eich cyfrinair yn weladwy.",
+                "passwordHiddenAnnouncementText": "Mae eich cyfrinair wedi'i guddio."
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\" data-i18n.show-password=\"Datguddia\" data-i18n.hide-password=\"Cuddio\" data-i18n.show-password-aria-label=\"Datgelu cyfrinair\" data-i18n.hide-password-aria-label=\"Cuddio cyfrinair\" data-i18n.password-shown-announcement=\"Mae eich cyfrinair yn weladwy.\" data-i18n.password-hidden-announcement=\"Mae eich cyfrinair wedi&#39;i guddio.\">\n  <label class=\"govuk-label\" for=\"password-translated\">\n    Cyfrinair\n  </label>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input\" id=\"password-translated\" name=\"password-translated\" type=\"password\" spellcheck=\"false\" autocomplete=\"current-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"password-translated\" aria-label=\"Datgelu cyfrinair\" hidden>\n      Datguddia\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "classes",
+            "options": {
+                "id": "with-classes",
+                "name": "with-classes",
+                "label": {
+                    "text": "With classes"
+                },
+                "classes": "app-input--custom-modifier"
+            },
+            "hidden": true,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\">\n  <label class=\"govuk-label\" for=\"with-classes\">\n    With classes\n  </label>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input app-input--custom-modifier\" id=\"with-classes\" name=\"with-classes\" type=\"password\" spellcheck=\"false\" autocomplete=\"current-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"with-classes\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "value",
+            "options": {
+                "id": "with-value",
+                "name": "with-value",
+                "label": {
+                    "text": "With value"
+                },
+                "value": "Hunter2"
+            },
+            "hidden": true,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\">\n  <label class=\"govuk-label\" for=\"with-value\">\n    With value\n  </label>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input\" id=\"with-value\" name=\"with-value\" type=\"password\" spellcheck=\"false\" value=\"Hunter2\" autocomplete=\"current-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"with-value\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "attributes",
+            "options": {
+                "id": "with-attributes",
+                "name": "with-attributes",
+                "label": {
+                    "text": "With attributes"
+                },
+                "attributes": {
+                    "data-attribute": "value",
+                    "data-another": "ok"
+                }
+            },
+            "hidden": true,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\">\n  <label class=\"govuk-label\" for=\"with-attributes\">\n    With attributes\n  </label>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input\" id=\"with-attributes\" name=\"with-attributes\" type=\"password\" spellcheck=\"false\" autocomplete=\"current-password\" autocapitalize=\"none\" data-attribute=\"value\" data-another=\"ok\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"with-attributes\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        },
+        {
+            "name": "with describedBy",
+            "options": {
+                "id": "with-describedby",
+                "name": "with-describedby",
+                "label": {
+                    "text": "With describedBy"
+                },
+                "describedBy": "test-target-element"
+            },
+            "hidden": true,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "html": "<div class=\"govuk-form-group govuk-password-input\" data-module=\"govuk-password-input\">\n  <label class=\"govuk-label\" for=\"with-describedby\">\n    With describedBy\n  </label>\n  <div class=\"govuk-input__wrapper govuk-password-input__wrapper\">\n    <input class=\"govuk-input govuk-password-input__input govuk-js-password-input-input\" id=\"with-describedby\" name=\"with-describedby\" type=\"password\" spellcheck=\"false\" aria-describedby=\"test-target-element\" autocomplete=\"current-password\" autocapitalize=\"none\">\n    <button type=\"button\" class=\"govuk-button govuk-button--secondary govuk-password-input__toggle govuk-js-password-input-toggle\" data-module=\"govuk-button\" aria-controls=\"with-describedby\" aria-label=\"Show password\" hidden>\n      Show\n    </button>\n  </div>\n</div>"
+        }
+    ]
+}
diff --git a/packages/govuk-frontend/dist/govuk/components/password-input/macro-options.json b/packages/govuk-frontend/dist/govuk/components/password-input/macro-options.json
new file mode 100644
index 000000000..4cf0d6a7e
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/password-input/macro-options.json
@@ -0,0 +1,181 @@
+[
+    {
+        "name": "id",
+        "type": "string",
+        "required": true,
+        "description": "The ID of the input."
+    },
+    {
+        "name": "name",
+        "type": "string",
+        "required": true,
+        "description": "The name of the input, which is submitted with the form data."
+    },
+    {
+        "name": "value",
+        "type": "string",
+        "required": false,
+        "description": "Optional initial value of the input."
+    },
+    {
+        "name": "disabled",
+        "type": "boolean",
+        "required": false,
+        "description": "If `true`, input will be disabled."
+    },
+    {
+        "name": "describedBy",
+        "type": "string",
+        "required": false,
+        "description": "One or more element IDs to add to the `aria-describedby` attribute, used to provide additional descriptive information for screenreader users."
+    },
+    {
+        "name": "label",
+        "type": "object",
+        "required": true,
+        "description": "The label used by the text input component.",
+        "isComponent": true
+    },
+    {
+        "name": "hint",
+        "type": "object",
+        "required": false,
+        "description": "Can be used to add a hint to a text input component.",
+        "isComponent": true
+    },
+    {
+        "name": "errorMessage",
+        "type": "object",
+        "required": false,
+        "description": "Can be used to add an error message to the text input component. The error message component will not display if you use a falsy value for `errorMessage`, for example `false` or `null`.",
+        "isComponent": true
+    },
+    {
+        "name": "formGroup",
+        "type": "object",
+        "required": false,
+        "description": "Additional options for the form group containing the text input component.",
+        "params": [
+            {
+                "name": "classes",
+                "type": "string",
+                "required": false,
+                "description": "Classes to add to the form group (for example to show error state for the whole group)."
+            },
+            {
+                "name": "attributes",
+                "type": "object",
+                "required": false,
+                "description": "HTML attributes (for example data attributes) to add to the form group."
+            },
+            {
+                "name": "beforeInput",
+                "type": "object",
+                "required": false,
+                "description": "Content to add before the input used by the text input component.",
+                "params": [
+                    {
+                        "name": "text",
+                        "type": "string",
+                        "required": true,
+                        "description": "Text to add before the input. If `html` is provided, the `text` option will be ignored."
+                    },
+                    {
+                        "name": "html",
+                        "type": "string",
+                        "required": true,
+                        "description": "HTML to add before the input. If `html` is provided, the `text` option will be ignored."
+                    }
+                ]
+            },
+            {
+                "name": "afterInput",
+                "type": "object",
+                "required": false,
+                "description": "Content to add after the input used by the text input component.",
+                "params": [
+                    {
+                        "name": "text",
+                        "type": "string",
+                        "required": true,
+                        "description": "Text to add after the input. If `html` is provided, the `text` option will be ignored."
+                    },
+                    {
+                        "name": "html",
+                        "type": "string",
+                        "required": true,
+                        "description": "HTML to add after the input. If `html` is provided, the `text` option will be ignored."
+                    }
+                ]
+            }
+        ]
+    },
+    {
+        "name": "classes",
+        "type": "string",
+        "required": false,
+        "description": "Classes to add to the input."
+    },
+    {
+        "name": "autocomplete",
+        "type": "string",
+        "required": false,
+        "description": "Attribute to [identify input purpose](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html). See [autofill](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill) for full list of values that can be used. Default is `\"current-password\"`."
+    },
+    {
+        "name": "attributes",
+        "type": "object",
+        "required": false,
+        "description": "HTML attributes (for example data attributes) to add to the input."
+    },
+    {
+        "name": "showPasswordText",
+        "type": "string",
+        "required": false,
+        "description": "Button text when the password is hidden. Defaults to `\"Show\"`."
+    },
+    {
+        "name": "hidePasswordText",
+        "type": "string",
+        "required": false,
+        "description": "Button text when the password is visible. Defaults to `\"Hide\"`."
+    },
+    {
+        "name": "showPasswordAriaLabelText",
+        "type": "string",
+        "required": false,
+        "description": "Button text exposed to assistive technologies, like screen readers, when the password is hidden. Defaults to `\"Show password\"`."
+    },
+    {
+        "name": "hidePasswordAriaLabelText",
+        "type": "string",
+        "required": false,
+        "description": "Button text exposed to assistive technologies, like screen readers, when the password is visible. Defaults to `\"Hide password\"`."
+    },
+    {
+        "name": "passwordShownAnnouncementText",
+        "type": "string",
+        "required": false,
+        "description": "Announcement made to screen reader users when their password has become visible in plain text. Defaults to `\"Your password is visible\"`."
+    },
+    {
+        "name": "passwordHiddenAnnouncementText",
+        "type": "string",
+        "required": false,
+        "description": "Announcement made to screen reader users when their password has been obscured and is not visible. Defaults to `\"Your password is hidden\"`."
+    },
+    {
+        "name": "button",
+        "type": "object",
+        "required": false,
+        "description": "Optional object allowing customisation of the toggle button.",
+        "params": [
+            {
+                "name": "classes",
+                "type": "string",
+                "required": false,
+                "description": "Classes to add to the button."
+            }
+        ]
+    }
+]
diff --git a/packages/govuk-frontend/dist/govuk/components/password-input/password-input.bundle.js b/packages/govuk-frontend/dist/govuk/components/password-input/password-input.bundle.js
new file mode 100644
index 000000000..fbc397bb2
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/password-input/password-input.bundle.js
@@ -0,0 +1,528 @@
+(function (global, factory) {
+  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+  typeof define === 'function' && define.amd ? define(['exports'], factory) :
+  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {}));
+})(this, (function (exports) { 'use strict';
+
+  function closestAttributeValue($element, attributeName) {
+    const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
+    return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
+  }
+
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
+      }
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
+      }
+    }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
+    }
+    return output;
+  }
+
+  /**
+   * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+   */
+
+  function mergeConfigs(...configObjects) {
+    const formattedConfigObject = {};
+    for (const configObject of configObjects) {
+      for (const key of Object.keys(configObject)) {
+        const option = formattedConfigObject[key];
+        const override = configObject[key];
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
+      }
+    }
+    return formattedConfigObject;
+  }
+  function extractConfigByNamespace(Component, dataset, namespace) {
+    const property = Component.schema.properties[namespace];
+    if ((property == null ? void 0 : property.type) !== 'object') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
+      const keyParts = key.split('.');
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
+        }
+      }
+    }
+    return newObject[namespace];
+  }
+  function isSupported($scope = document.body) {
+    if (!$scope) {
+      return false;
+    }
+    return $scope.classList.contains('govuk-frontend-supported');
+  }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
+
+  /**
+   * Schema for component config
+   *
+   * @typedef {object} Schema
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
+  /**
+   * Schema condition for component config
+   *
+   * @typedef {object} SchemaCondition
+   * @property {string[]} required - List of required config fields
+   * @property {string} errorMessage - Error message when required config fields not provided
+   */
+
+  function normaliseDataset(Component, dataset) {
+    const out = {};
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
+    }
+    return out;
+  }
+
+  class GOVUKFrontendError extends Error {
+    constructor(...args) {
+      super(...args);
+      this.name = 'GOVUKFrontendError';
+    }
+  }
+  class SupportError extends GOVUKFrontendError {
+    /**
+     * Checks if GOV.UK Frontend is supported on this page
+     *
+     * @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
+     */
+    constructor($scope = document.body) {
+      const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser';
+      super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`');
+      this.name = 'SupportError';
+    }
+  }
+  class ElementError extends GOVUKFrontendError {
+    constructor(messageOrOptions) {
+      let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
+      if (typeof messageOrOptions === 'object') {
+        const {
+          componentName,
+          identifier,
+          element,
+          expectedType
+        } = messageOrOptions;
+        message = `${componentName}: ${identifier}`;
+        message += element ? ` is not of type ${expectedType != null ? expectedType : 'HTMLElement'}` : ' not found';
+      }
+      super(message);
+      this.name = 'ElementError';
+    }
+  }
+
+  class GOVUKFrontendComponent {
+    constructor() {
+      this.checkSupport();
+    }
+    checkSupport() {
+      if (!isSupported()) {
+        throw new SupportError();
+      }
+    }
+  }
+
+  class I18n {
+    constructor(translations = {}, config = {}) {
+      var _config$locale;
+      this.translations = void 0;
+      this.locale = void 0;
+      this.translations = translations;
+      this.locale = (_config$locale = config.locale) != null ? _config$locale : document.documentElement.lang || 'en';
+    }
+    t(lookupKey, options) {
+      if (!lookupKey) {
+        throw new Error('i18n: lookup key missing');
+      }
+      let translation = this.translations[lookupKey];
+      if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+        const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+        if (translationPluralForm) {
+          translation = translationPluralForm;
+        }
+      }
+      if (typeof translation === 'string') {
+        if (translation.match(/%{(.\S+)}/)) {
+          if (!options) {
+            throw new Error('i18n: cannot replace placeholders in string if no option data provided');
+          }
+          return this.replacePlaceholders(translation, options);
+        }
+        return translation;
+      }
+      return lookupKey;
+    }
+    replacePlaceholders(translationString, options) {
+      const formatter = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : undefined;
+      return translationString.replace(/%{(.\S+)}/g, function (placeholderWithBraces, placeholderKey) {
+        if (Object.prototype.hasOwnProperty.call(options, placeholderKey)) {
+          const placeholderValue = options[placeholderKey];
+          if (placeholderValue === false || typeof placeholderValue !== 'number' && typeof placeholderValue !== 'string') {
+            return '';
+          }
+          if (typeof placeholderValue === 'number') {
+            return formatter ? formatter.format(placeholderValue) : `${placeholderValue}`;
+          }
+          return placeholderValue;
+        }
+        throw new Error(`i18n: no data found to replace ${placeholderWithBraces} placeholder in string`);
+      });
+    }
+    hasIntlPluralRulesSupport() {
+      return Boolean('PluralRules' in window.Intl && Intl.PluralRules.supportedLocalesOf(this.locale).length);
+    }
+    getPluralSuffix(lookupKey, count) {
+      count = Number(count);
+      if (!isFinite(count)) {
+        return 'other';
+      }
+      const translation = this.translations[lookupKey];
+      const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
+      if (typeof translation === 'object') {
+        if (preferredForm in translation) {
+          return preferredForm;
+        } else if ('other' in translation) {
+          console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+          return 'other';
+        }
+      }
+      throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
+    }
+    selectPluralFormUsingFallbackRules(count) {
+      count = Math.abs(Math.floor(count));
+      const ruleset = this.getPluralRulesForLocale();
+      if (ruleset) {
+        return I18n.pluralRules[ruleset](count);
+      }
+      return 'other';
+    }
+    getPluralRulesForLocale() {
+      const localeShort = this.locale.split('-')[0];
+      for (const pluralRule in I18n.pluralRulesMap) {
+        const languages = I18n.pluralRulesMap[pluralRule];
+        if (languages.includes(this.locale) || languages.includes(localeShort)) {
+          return pluralRule;
+        }
+      }
+    }
+  }
+  I18n.pluralRulesMap = {
+    arabic: ['ar'],
+    chinese: ['my', 'zh', 'id', 'ja', 'jv', 'ko', 'ms', 'th', 'vi'],
+    french: ['hy', 'bn', 'fr', 'gu', 'hi', 'fa', 'pa', 'zu'],
+    german: ['af', 'sq', 'az', 'eu', 'bg', 'ca', 'da', 'nl', 'en', 'et', 'fi', 'ka', 'de', 'el', 'hu', 'lb', 'no', 'so', 'sw', 'sv', 'ta', 'te', 'tr', 'ur'],
+    irish: ['ga'],
+    russian: ['ru', 'uk'],
+    scottish: ['gd'],
+    spanish: ['pt-PT', 'it', 'es'],
+    welsh: ['cy']
+  };
+  I18n.pluralRules = {
+    arabic(n) {
+      if (n === 0) {
+        return 'zero';
+      }
+      if (n === 1) {
+        return 'one';
+      }
+      if (n === 2) {
+        return 'two';
+      }
+      if (n % 100 >= 3 && n % 100 <= 10) {
+        return 'few';
+      }
+      if (n % 100 >= 11 && n % 100 <= 99) {
+        return 'many';
+      }
+      return 'other';
+    },
+    chinese() {
+      return 'other';
+    },
+    french(n) {
+      return n === 0 || n === 1 ? 'one' : 'other';
+    },
+    german(n) {
+      return n === 1 ? 'one' : 'other';
+    },
+    irish(n) {
+      if (n === 1) {
+        return 'one';
+      }
+      if (n === 2) {
+        return 'two';
+      }
+      if (n >= 3 && n <= 6) {
+        return 'few';
+      }
+      if (n >= 7 && n <= 10) {
+        return 'many';
+      }
+      return 'other';
+    },
+    russian(n) {
+      const lastTwo = n % 100;
+      const last = lastTwo % 10;
+      if (last === 1 && lastTwo !== 11) {
+        return 'one';
+      }
+      if (last >= 2 && last <= 4 && !(lastTwo >= 12 && lastTwo <= 14)) {
+        return 'few';
+      }
+      if (last === 0 || last >= 5 && last <= 9 || lastTwo >= 11 && lastTwo <= 14) {
+        return 'many';
+      }
+      return 'other';
+    },
+    scottish(n) {
+      if (n === 1 || n === 11) {
+        return 'one';
+      }
+      if (n === 2 || n === 12) {
+        return 'two';
+      }
+      if (n >= 3 && n <= 10 || n >= 13 && n <= 19) {
+        return 'few';
+      }
+      return 'other';
+    },
+    spanish(n) {
+      if (n === 1) {
+        return 'one';
+      }
+      if (n % 1000000 === 0 && n !== 0) {
+        return 'many';
+      }
+      return 'other';
+    },
+    welsh(n) {
+      if (n === 0) {
+        return 'zero';
+      }
+      if (n === 1) {
+        return 'one';
+      }
+      if (n === 2) {
+        return 'two';
+      }
+      if (n === 3) {
+        return 'few';
+      }
+      if (n === 6) {
+        return 'many';
+      }
+      return 'other';
+    }
+  };
+
+  /**
+   * Password input component
+   *
+   * @preserve
+   */
+  class PasswordInput extends GOVUKFrontendComponent {
+    /**
+     * @param {Element | null} $module - HTML element to use for password input
+     * @param {PasswordInputConfig} [config] - Password input config
+     */
+    constructor($module, config = {}) {
+      super();
+      this.$module = void 0;
+      this.config = void 0;
+      this.i18n = void 0;
+      this.$input = void 0;
+      this.$showHideButton = void 0;
+      this.$screenReaderStatusMessage = void 0;
+      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, {
+        locale: closestAttributeValue($module, 'lang')
+      });
+      this.$showHideButton.removeAttribute('hidden');
+      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);
+      this.$showHideButton.addEventListener('click', this.toggle.bind(this));
+      if (this.$input.form) {
+        this.$input.form.addEventListener('submit', () => this.hide());
+      }
+      window.addEventListener('pageshow', event => {
+        if (event.persisted && this.$input.type !== 'password') {
+          this.hide();
+        }
+      });
+      this.hide();
+    }
+    toggle(event) {
+      event.preventDefault();
+      if (this.$input.type === 'password') {
+        this.show();
+        return;
+      }
+      this.hide();
+    }
+    show() {
+      this.setType('text');
+    }
+    hide() {
+      this.setType('password');
+    }
+    setType(type) {
+      if (type === this.$input.type) {
+        return;
+      }
+      this.$input.setAttribute('type', type);
+      const isHidden = type === 'password';
+      const prefixButton = isHidden ? 'show' : 'hide';
+      const prefixStatus = isHidden ? 'passwordHidden' : 'passwordShown';
+      this.$showHideButton.innerHTML = this.i18n.t(`${prefixButton}Password`);
+      this.$showHideButton.setAttribute('aria-label', this.i18n.t(`${prefixButton}PasswordAriaLabel`));
+      this.$screenReaderStatusMessage.innerText = this.i18n.t(`${prefixStatus}Announcement`);
+    }
+  }
+
+  /**
+   * 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
+   */
+  PasswordInput.moduleName = 'govuk-password-input';
+  PasswordInput.defaults = Object.freeze({
+    i18n: {
+      showPassword: 'Show',
+      hidePassword: 'Hide',
+      showPasswordAriaLabel: 'Show password',
+      hidePasswordAriaLabel: 'Hide password',
+      passwordShownAnnouncement: 'Your password is visible',
+      passwordHiddenAnnouncement: 'Your password is hidden'
+    }
+  });
+  PasswordInput.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
+
+  exports.PasswordInput = PasswordInput;
+
+}));
+//# sourceMappingURL=password-input.bundle.js.map
diff --git a/packages/govuk-frontend/dist/govuk/components/password-input/password-input.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/password-input/password-input.bundle.mjs
new file mode 100644
index 000000000..f6eccf100
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/password-input/password-input.bundle.mjs
@@ -0,0 +1,520 @@
+function closestAttributeValue($element, attributeName) {
+  const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
+  return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
+}
+
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
+    }
+  }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
+function mergeConfigs(...configObjects) {
+  const formattedConfigObject = {};
+  for (const configObject of configObjects) {
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
+    }
+  }
+  return formattedConfigObject;
+}
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
+    const keyParts = key.split('.');
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
+      }
+    }
+  }
+  return newObject[namespace];
+}
+function isSupported($scope = document.body) {
+  if (!$scope) {
+    return false;
+  }
+  return $scope.classList.contains('govuk-frontend-supported');
+}
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
+
+/**
+ * Schema for component config
+ *
+ * @typedef {object} Schema
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
+/**
+ * Schema condition for component config
+ *
+ * @typedef {object} SchemaCondition
+ * @property {string[]} required - List of required config fields
+ * @property {string} errorMessage - Error message when required config fields not provided
+ */
+
+function normaliseDataset(Component, dataset) {
+  const out = {};
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
+  }
+  return out;
+}
+
+class GOVUKFrontendError extends Error {
+  constructor(...args) {
+    super(...args);
+    this.name = 'GOVUKFrontendError';
+  }
+}
+class SupportError extends GOVUKFrontendError {
+  /**
+   * Checks if GOV.UK Frontend is supported on this page
+   *
+   * @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
+   */
+  constructor($scope = document.body) {
+    const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser';
+    super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`');
+    this.name = 'SupportError';
+  }
+}
+class ElementError extends GOVUKFrontendError {
+  constructor(messageOrOptions) {
+    let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
+    if (typeof messageOrOptions === 'object') {
+      const {
+        componentName,
+        identifier,
+        element,
+        expectedType
+      } = messageOrOptions;
+      message = `${componentName}: ${identifier}`;
+      message += element ? ` is not of type ${expectedType != null ? expectedType : 'HTMLElement'}` : ' not found';
+    }
+    super(message);
+    this.name = 'ElementError';
+  }
+}
+
+class GOVUKFrontendComponent {
+  constructor() {
+    this.checkSupport();
+  }
+  checkSupport() {
+    if (!isSupported()) {
+      throw new SupportError();
+    }
+  }
+}
+
+class I18n {
+  constructor(translations = {}, config = {}) {
+    var _config$locale;
+    this.translations = void 0;
+    this.locale = void 0;
+    this.translations = translations;
+    this.locale = (_config$locale = config.locale) != null ? _config$locale : document.documentElement.lang || 'en';
+  }
+  t(lookupKey, options) {
+    if (!lookupKey) {
+      throw new Error('i18n: lookup key missing');
+    }
+    let translation = this.translations[lookupKey];
+    if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+      const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+      if (translationPluralForm) {
+        translation = translationPluralForm;
+      }
+    }
+    if (typeof translation === 'string') {
+      if (translation.match(/%{(.\S+)}/)) {
+        if (!options) {
+          throw new Error('i18n: cannot replace placeholders in string if no option data provided');
+        }
+        return this.replacePlaceholders(translation, options);
+      }
+      return translation;
+    }
+    return lookupKey;
+  }
+  replacePlaceholders(translationString, options) {
+    const formatter = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : undefined;
+    return translationString.replace(/%{(.\S+)}/g, function (placeholderWithBraces, placeholderKey) {
+      if (Object.prototype.hasOwnProperty.call(options, placeholderKey)) {
+        const placeholderValue = options[placeholderKey];
+        if (placeholderValue === false || typeof placeholderValue !== 'number' && typeof placeholderValue !== 'string') {
+          return '';
+        }
+        if (typeof placeholderValue === 'number') {
+          return formatter ? formatter.format(placeholderValue) : `${placeholderValue}`;
+        }
+        return placeholderValue;
+      }
+      throw new Error(`i18n: no data found to replace ${placeholderWithBraces} placeholder in string`);
+    });
+  }
+  hasIntlPluralRulesSupport() {
+    return Boolean('PluralRules' in window.Intl && Intl.PluralRules.supportedLocalesOf(this.locale).length);
+  }
+  getPluralSuffix(lookupKey, count) {
+    count = Number(count);
+    if (!isFinite(count)) {
+      return 'other';
+    }
+    const translation = this.translations[lookupKey];
+    const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
+    if (typeof translation === 'object') {
+      if (preferredForm in translation) {
+        return preferredForm;
+      } else if ('other' in translation) {
+        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+        return 'other';
+      }
+    }
+    throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
+  }
+  selectPluralFormUsingFallbackRules(count) {
+    count = Math.abs(Math.floor(count));
+    const ruleset = this.getPluralRulesForLocale();
+    if (ruleset) {
+      return I18n.pluralRules[ruleset](count);
+    }
+    return 'other';
+  }
+  getPluralRulesForLocale() {
+    const localeShort = this.locale.split('-')[0];
+    for (const pluralRule in I18n.pluralRulesMap) {
+      const languages = I18n.pluralRulesMap[pluralRule];
+      if (languages.includes(this.locale) || languages.includes(localeShort)) {
+        return pluralRule;
+      }
+    }
+  }
+}
+I18n.pluralRulesMap = {
+  arabic: ['ar'],
+  chinese: ['my', 'zh', 'id', 'ja', 'jv', 'ko', 'ms', 'th', 'vi'],
+  french: ['hy', 'bn', 'fr', 'gu', 'hi', 'fa', 'pa', 'zu'],
+  german: ['af', 'sq', 'az', 'eu', 'bg', 'ca', 'da', 'nl', 'en', 'et', 'fi', 'ka', 'de', 'el', 'hu', 'lb', 'no', 'so', 'sw', 'sv', 'ta', 'te', 'tr', 'ur'],
+  irish: ['ga'],
+  russian: ['ru', 'uk'],
+  scottish: ['gd'],
+  spanish: ['pt-PT', 'it', 'es'],
+  welsh: ['cy']
+};
+I18n.pluralRules = {
+  arabic(n) {
+    if (n === 0) {
+      return 'zero';
+    }
+    if (n === 1) {
+      return 'one';
+    }
+    if (n === 2) {
+      return 'two';
+    }
+    if (n % 100 >= 3 && n % 100 <= 10) {
+      return 'few';
+    }
+    if (n % 100 >= 11 && n % 100 <= 99) {
+      return 'many';
+    }
+    return 'other';
+  },
+  chinese() {
+    return 'other';
+  },
+  french(n) {
+    return n === 0 || n === 1 ? 'one' : 'other';
+  },
+  german(n) {
+    return n === 1 ? 'one' : 'other';
+  },
+  irish(n) {
+    if (n === 1) {
+      return 'one';
+    }
+    if (n === 2) {
+      return 'two';
+    }
+    if (n >= 3 && n <= 6) {
+      return 'few';
+    }
+    if (n >= 7 && n <= 10) {
+      return 'many';
+    }
+    return 'other';
+  },
+  russian(n) {
+    const lastTwo = n % 100;
+    const last = lastTwo % 10;
+    if (last === 1 && lastTwo !== 11) {
+      return 'one';
+    }
+    if (last >= 2 && last <= 4 && !(lastTwo >= 12 && lastTwo <= 14)) {
+      return 'few';
+    }
+    if (last === 0 || last >= 5 && last <= 9 || lastTwo >= 11 && lastTwo <= 14) {
+      return 'many';
+    }
+    return 'other';
+  },
+  scottish(n) {
+    if (n === 1 || n === 11) {
+      return 'one';
+    }
+    if (n === 2 || n === 12) {
+      return 'two';
+    }
+    if (n >= 3 && n <= 10 || n >= 13 && n <= 19) {
+      return 'few';
+    }
+    return 'other';
+  },
+  spanish(n) {
+    if (n === 1) {
+      return 'one';
+    }
+    if (n % 1000000 === 0 && n !== 0) {
+      return 'many';
+    }
+    return 'other';
+  },
+  welsh(n) {
+    if (n === 0) {
+      return 'zero';
+    }
+    if (n === 1) {
+      return 'one';
+    }
+    if (n === 2) {
+      return 'two';
+    }
+    if (n === 3) {
+      return 'few';
+    }
+    if (n === 6) {
+      return 'many';
+    }
+    return 'other';
+  }
+};
+
+/**
+ * Password input component
+ *
+ * @preserve
+ */
+class PasswordInput extends GOVUKFrontendComponent {
+  /**
+   * @param {Element | null} $module - HTML element to use for password input
+   * @param {PasswordInputConfig} [config] - Password input config
+   */
+  constructor($module, config = {}) {
+    super();
+    this.$module = void 0;
+    this.config = void 0;
+    this.i18n = void 0;
+    this.$input = void 0;
+    this.$showHideButton = void 0;
+    this.$screenReaderStatusMessage = void 0;
+    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, {
+      locale: closestAttributeValue($module, 'lang')
+    });
+    this.$showHideButton.removeAttribute('hidden');
+    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);
+    this.$showHideButton.addEventListener('click', this.toggle.bind(this));
+    if (this.$input.form) {
+      this.$input.form.addEventListener('submit', () => this.hide());
+    }
+    window.addEventListener('pageshow', event => {
+      if (event.persisted && this.$input.type !== 'password') {
+        this.hide();
+      }
+    });
+    this.hide();
+  }
+  toggle(event) {
+    event.preventDefault();
+    if (this.$input.type === 'password') {
+      this.show();
+      return;
+    }
+    this.hide();
+  }
+  show() {
+    this.setType('text');
+  }
+  hide() {
+    this.setType('password');
+  }
+  setType(type) {
+    if (type === this.$input.type) {
+      return;
+    }
+    this.$input.setAttribute('type', type);
+    const isHidden = type === 'password';
+    const prefixButton = isHidden ? 'show' : 'hide';
+    const prefixStatus = isHidden ? 'passwordHidden' : 'passwordShown';
+    this.$showHideButton.innerHTML = this.i18n.t(`${prefixButton}Password`);
+    this.$showHideButton.setAttribute('aria-label', this.i18n.t(`${prefixButton}PasswordAriaLabel`));
+    this.$screenReaderStatusMessage.innerText = this.i18n.t(`${prefixStatus}Announcement`);
+  }
+}
+
+/**
+ * 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
+ */
+PasswordInput.moduleName = 'govuk-password-input';
+PasswordInput.defaults = Object.freeze({
+  i18n: {
+    showPassword: 'Show',
+    hidePassword: 'Hide',
+    showPasswordAriaLabel: 'Show password',
+    hidePasswordAriaLabel: 'Hide password',
+    passwordShownAnnouncement: 'Your password is visible',
+    passwordHiddenAnnouncement: 'Your password is hidden'
+  }
+});
+PasswordInput.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+
+export { PasswordInput };
+//# sourceMappingURL=password-input.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/password-input/password-input.mjs b/packages/govuk-frontend/dist/govuk/components/password-input/password-input.mjs
new file mode 100644
index 000000000..f4e133fc3
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/password-input/password-input.mjs
@@ -0,0 +1,163 @@
+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
+ */
+class PasswordInput extends GOVUKFrontendComponent {
+  /**
+   * @param {Element | null} $module - HTML element to use for password input
+   * @param {PasswordInputConfig} [config] - Password input config
+   */
+  constructor($module, config = {}) {
+    super();
+    this.$module = void 0;
+    this.config = void 0;
+    this.i18n = void 0;
+    this.$input = void 0;
+    this.$showHideButton = void 0;
+    this.$screenReaderStatusMessage = void 0;
+    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, {
+      locale: closestAttributeValue($module, 'lang')
+    });
+    this.$showHideButton.removeAttribute('hidden');
+    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);
+    this.$showHideButton.addEventListener('click', this.toggle.bind(this));
+    if (this.$input.form) {
+      this.$input.form.addEventListener('submit', () => this.hide());
+    }
+    window.addEventListener('pageshow', event => {
+      if (event.persisted && this.$input.type !== 'password') {
+        this.hide();
+      }
+    });
+    this.hide();
+  }
+  toggle(event) {
+    event.preventDefault();
+    if (this.$input.type === 'password') {
+      this.show();
+      return;
+    }
+    this.hide();
+  }
+  show() {
+    this.setType('text');
+  }
+  hide() {
+    this.setType('password');
+  }
+  setType(type) {
+    if (type === this.$input.type) {
+      return;
+    }
+    this.$input.setAttribute('type', type);
+    const isHidden = type === 'password';
+    const prefixButton = isHidden ? 'show' : 'hide';
+    const prefixStatus = isHidden ? 'passwordHidden' : 'passwordShown';
+    this.$showHideButton.innerHTML = this.i18n.t(`${prefixButton}Password`);
+    this.$showHideButton.setAttribute('aria-label', this.i18n.t(`${prefixButton}PasswordAriaLabel`));
+    this.$screenReaderStatusMessage.innerText = this.i18n.t(`${prefixStatus}Announcement`);
+  }
+}
+
+/**
+ * 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
+ */
+PasswordInput.moduleName = 'govuk-password-input';
+PasswordInput.defaults = Object.freeze({
+  i18n: {
+    showPassword: 'Show',
+    hidePassword: 'Hide',
+    showPasswordAriaLabel: 'Show password',
+    hidePasswordAriaLabel: 'Hide password',
+    passwordShownAnnouncement: 'Your password is visible',
+    passwordHiddenAnnouncement: 'Your password is hidden'
+  }
+});
+PasswordInput.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+
+export { PasswordInput };
+//# sourceMappingURL=password-input.mjs.map

Action run for 2b5d976

@querkmachine querkmachine requested a review from a team March 14, 2024 11:01
@querkmachine querkmachine marked this pull request as ready for review March 14, 2024 11:34
Testing a hypothesis that VoiceOver failing to read the aria-live consistently may be due to it only being added when a status is set for the first time.

If this is the case, then potentially it's also unnecessary to use assertive over polite.
Copy link
Contributor

@owenatgov owenatgov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking sharp. Done some browser testing and screen reader testing. All looking good to me. Code looking good as well. I've left a single very pithy comment that's far from critical.

I haven't looked at the puppeteer tests very much as it looks like this is being handled in #4763.

@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-4442 March 15, 2024 11:19 Inactive
@querkmachine
Copy link
Member Author

Anika has concluded testing 80d33b4 and found that it indeed resolves the issues with VoiceOver status announcements without negatively affecting other screen readers.

Reverting when the aria-live attribute is added does mean that JAWS and NVDA will re-announce the status in the event a user returns to the page via the bfcache, after having previously entered a value and toggled it to be visible before leaving.

I would consider this issue to be secondary to ensuring that announcements occur consistently on the 'happy path' in VoiceOver, however, so the reversion feels sensible.

With that, I think this is mergeable! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Build password input component
6 participants