commit 7b5713df6ffec06189294d497ea4c8bac40b0f64
parent c858cd0fdd613c8f96e27e275ff0186d80d97dd4
Author: Harsheet <hsohaney@mozilla.com>
Date: Thu, 6 Nov 2025 21:25:43 +0000
Bug 1993111 - Fix tooltip and focus behavior for password field and checkbox in dialog. r=desktop-theme-reviewers,mconley,emilio
Differential Revision: https://phabricator.services.mozilla.com/D269256
Diffstat:
6 files changed, 175 insertions(+), 63 deletions(-)
diff --git a/browser/components/backup/content/password-rules-tooltip.css b/browser/components/backup/content/password-rules-tooltip.css
@@ -2,15 +2,15 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
-#password-rules-wrapper {
+#password-rules-wrapper[popover] {
margin: 0;
- position: absolute;
padding: var(--space-large) var(--space-xlarge);
background-color: var(--background-color-box);
border: var(--border-width) solid var(--border-color-interactive);
border-radius: var(--border-radius-small);
overflow: visible;
- z-index: 10;
+
+ margin-block-start: 0.5rem;
ul {
margin: 0;
diff --git a/browser/components/backup/content/password-rules-tooltip.mjs b/browser/components/backup/content/password-rules-tooltip.mjs
@@ -13,6 +13,7 @@ export default class PasswordRulesTooltip extends MozLitElement {
static properties = {
hasEmail: { type: Boolean },
tooShort: { type: Boolean },
+ open: { type: Boolean },
};
static get queries() {
@@ -25,6 +26,75 @@ export default class PasswordRulesTooltip extends MozLitElement {
super();
this.hasEmail = false;
this.tooShort = false;
+ this._onResize = null;
+ }
+
+ _debounce(fn, delay) {
+ let timeout;
+ return (...args) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => fn(...args), delay);
+ };
+ }
+
+ _handleResize() {
+ if (this.open) {
+ this.positionPopover();
+ }
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this._onResize = this._debounce(() => this._handleResize(), 200);
+ window.addEventListener("resize", this._onResize);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this._onResize) {
+ window.removeEventListener("resize", this._onResize);
+ }
+ }
+
+ show() {
+ this.passwordRulesEl.showPopover();
+ this.positionPopover();
+ }
+
+ hide() {
+ this.passwordRulesEl.hidePopover();
+ }
+
+ positionPopover() {
+ const host = this.getRootNode().host;
+ const anchor = host.shadowRoot.querySelector("#new-password-input");
+
+ if (!anchor) {
+ return;
+ }
+
+ const anchorRect = anchor.getBoundingClientRect();
+ const isWideViewport = window.innerWidth >= 1200;
+
+ if (isWideViewport) {
+ // Position to the right of the input
+ const leftPos = anchorRect.right + 16;
+ const topPos = anchorRect.top - (anchorRect.bottom - anchorRect.top) / 2;
+
+ this.passwordRulesEl.style.left = `${leftPos}px`;
+ this.passwordRulesEl.style.top = `${topPos}px`;
+ } else {
+ // Position below the input
+ const leftPos = anchorRect.left;
+ const topPos = anchorRect.bottom; // offset for arrow and spacing
+
+ this.passwordRulesEl.style.left = `${leftPos}px`;
+ this.passwordRulesEl.style.top = `${topPos}px`;
+ }
+ }
+
+ _onBeforeToggle(e) {
+ this.open = e.newState == "open";
}
render() {
@@ -33,7 +103,13 @@ export default class PasswordRulesTooltip extends MozLitElement {
rel="stylesheet"
href="chrome://browser/content/backup/password-rules-tooltip.css"
/>
- <div id="password-rules-wrapper" aria-live="polite">
+ <div
+ id="password-rules-wrapper"
+ role="tooltip"
+ aria-describedby="password-rules-header"
+ popover="manual"
+ @beforetoggle=${this._onBeforeToggle}
+ >
<h2
id="password-rules-header"
data-l10n-id="password-rules-header"
@@ -43,12 +119,14 @@ export default class PasswordRulesTooltip extends MozLitElement {
<span
data-l10n-id="password-rules-length-description"
class="rule-description"
+ aria-labelledby="password-rules-header"
></span>
</li>
<li class=${this.hasEmail && "warning"}>
<span
data-l10n-id="password-rules-email-description"
class="rule-description"
+ aria-labelledby="password-rules-header"
></span>
</li>
</ul>
diff --git a/browser/components/backup/content/password-validation-inputs.css b/browser/components/backup/content/password-validation-inputs.css
@@ -71,3 +71,14 @@
transition: visibility 0s;
}
}
+
+.field-error {
+ display: none;
+ color: var(--text-color-error);
+ font-size: var(--font-size-small);
+ margin-block-start: var(--space-xsmall);
+}
+
+input:user-invalid + .field-error {
+ display: block;
+}
diff --git a/browser/components/backup/content/password-validation-inputs.mjs b/browser/components/backup/content/password-validation-inputs.mjs
@@ -17,13 +17,7 @@ export default class PasswordValidationInputs extends MozLitElement {
_hasEmail: { type: Boolean, state: true },
_passwordsMatch: { type: Boolean, state: true },
_passwordsValid: { type: Boolean, state: true },
- _showRules: { type: Boolean, state: true },
_tooShort: { type: Boolean, state: true },
- /**
- * If, by chance, there is focus on a focusable element in the tooltip,
- * track the focus state so that we can keep the tooltip open.
- */
- _tooltipFocus: { type: Boolean, state: true },
createPasswordLabelL10nId: {
type: String,
reflect: true,
@@ -42,6 +36,7 @@ export default class PasswordValidationInputs extends MozLitElement {
inputNewPasswordEl: "#new-password-input",
inputRepeatPasswordEl: "#repeat-password-input",
passwordRulesEl: "#password-rules",
+ repeatPasswordErrorEl: "#repeat-password-error",
};
}
@@ -51,27 +46,57 @@ export default class PasswordValidationInputs extends MozLitElement {
this._hasEmail = false;
this._passwordsMatch = false;
this._passwordsValid = false;
- this._tooltipFocus = false;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this._onKeydown = e => {
+ if (e.key === "Escape" && this.passwordRulesEl.open) {
+ this.passwordRulesEl.hide();
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ };
+ document.addEventListener("keydown", this._onKeydown, true);
+ }
+ disconnectedCallback() {
+ document.removeEventListener("keydown", this._onKeydown, true);
+ super.disconnectedCallback();
+ }
+
+ setInputValidity(input, isValid, describedById = null) {
+ input.setAttribute("aria-invalid", isValid ? "false" : "true");
+ if (describedById) {
+ input.setAttribute("aria-describedby", describedById);
+ } else {
+ input.removeAttribute("aria-describedby");
+ }
}
reset() {
- this.formEl.reset();
- this.inputNewPasswordEl.revealPassword = false;
- this.inputRepeatPasswordEl.revealPassword = false;
- this._showRules = false;
+ this.formEl?.reset();
+ if (this.inputNewPasswordEl) {
+ this.inputNewPasswordEl.revealPassword = false;
+ this.setInputValidity(this.inputNewPasswordEl, true);
+ }
+ if (this.inputRepeatPasswordEl) {
+ this.inputRepeatPasswordEl.revealPassword = false;
+ this.setInputValidity(this.inputRepeatPasswordEl, true);
+ }
this._hasEmail = false;
this._tooShort = true;
this._passwordsMatch = false;
this._passwordsValid = false;
- this._tooltipFocus = false;
}
handleFocusNewPassword() {
- this._showRules = true;
+ this.passwordRulesEl.show();
}
- handleBlurNewPassword() {
- this._showRules = false;
+ handleBlurNewPassword(event) {
+ if (event.target.checkValidity()) {
+ this.passwordRulesEl.hide();
+ }
}
handleChangeNewPassword() {
@@ -102,18 +127,32 @@ export default class PasswordValidationInputs extends MozLitElement {
const newPassValidity = this.inputNewPasswordEl.validity;
this._tooShort = newPassValidity?.valueMissing || newPassValidity?.tooShort;
+ const newInvalid = !newPassValidity?.valid;
+ this.setInputValidity(
+ this.inputNewPasswordEl,
+ !newInvalid,
+ "password-rules-tooltip"
+ );
+
this._passwordsMatch =
this.inputNewPasswordEl.value == this.inputRepeatPasswordEl.value;
- if (!this._passwordsMatch) {
- const passwords_do_not_match_l10n_message = l10n.formatValueSync(
- "password-validity-do-not-match"
- );
+ if (!this._passwordsMatch) {
this.inputRepeatPasswordEl.setCustomValidity(
- passwords_do_not_match_l10n_message
+ l10n.formatValueSync("password-validity-do-not-match")
+ );
+ this.setInputValidity(
+ this.inputRepeatPasswordEl,
+ false,
+ "repeat-password-error"
+ );
+ document.l10n.setAttributes(
+ this.repeatPasswordErrorEl,
+ "password-validity-do-not-match"
);
} else {
this.inputRepeatPasswordEl.setCustomValidity("");
+ this.setInputValidity(this.inputRepeatPasswordEl, true);
}
const repeatPassValidity = this.inputRepeatPasswordEl.validity;
@@ -121,20 +160,6 @@ export default class PasswordValidationInputs extends MozLitElement {
newPassValidity?.valid &&
repeatPassValidity?.valid &&
this._passwordsMatch;
-
- /**
- * This step may involve async validation with BackupService. For instance, we have to
- * check against a list of common passwords (bug 1905140) and display an error message if an
- * issue occurs (bug 1905145).
- */
- }
-
- handleTooltipFocus() {
- this._tooltipFocus = true;
- }
-
- handleTooltipBlur() {
- this._tooltipFocus = false;
}
/**
@@ -184,9 +209,11 @@ export default class PasswordValidationInputs extends MozLitElement {
id="new-password-input"
minlength="8"
required
+ aria-describedby="password-rules-tooltip"
@input=${this.handleChangeNewPassword}
- @focus=${this.handleFocusNewPassword}
@blur=${this.handleBlurNewPassword}
+ @mouseenter=${this.handleFocusNewPassword}
+ @focus=${this.handleFocusNewPassword}
/>
<!--TODO: (bug 1909984) improve how we read out the first input field for screen readers-->
</div>
@@ -194,11 +221,9 @@ export default class PasswordValidationInputs extends MozLitElement {
<!--TODO: (bug 1909984) look into how the tooltip vs dialog behaves when pressing the ESC key-->
<password-rules-tooltip
id="password-rules"
- class=${!this._showRules && !this._tooltipFocus ? "hidden" : ""}
+ role="tooltip"
.hasEmail=${this._hasEmail}
.tooShort=${this._tooShort}
- @focus=${this.handleTooltipFocus}
- @blur=${this.handleTooltipBlur}
?embedded-fx-backup-opt-in=${this.embeddedFxBackupOptIn}
></password-rules-tooltip>
<label id="repeat-password-label" for="repeat-password-input">
@@ -209,10 +234,14 @@ export default class PasswordValidationInputs extends MozLitElement {
<input
type="password"
id="repeat-password-input"
- minlength="8"
required
@input=${this.handleChangeRepeatPassword}
/>
+ <span
+ id="repeat-password-error"
+ role="alert"
+ class="field-error"
+ ></span>
</label>
</form>
</div>
diff --git a/browser/components/backup/tests/browser/browser_password_validation_inputs.js b/browser/components/backup/tests/browser/browser_password_validation_inputs.js
@@ -86,6 +86,15 @@ add_task(async function password_validation() {
validityStub.restore();
+ /*
+ * We can't use waitForMutationCondition because mutation observers do not detect computed style updates following a modified class name.
+ * Plus, visibility changes are delayed due to transitions. Use waitForCondition instead to wait for the animation to finish and
+ * validate the tooltip's final visibility state.
+ */
+ let hiddenPromise = BrowserTestUtils.waitForCondition(() => {
+ return !passwordInputs.passwordRulesEl.open;
+ });
+
// Now assume an email was entered
const mockEmail = "email@example.com";
await createMockPassInputEventPromise(newPasswordInput, mockEmail);
@@ -108,6 +117,9 @@ add_task(async function password_validation() {
await createMockPassInputEventPromise(repeatPasswordInput, noMatchPass);
await passwordInputs.updateComplete;
+ // Ensure that the popover is not open anymore
+ await hiddenPromise;
+
Assert.ok(
!passwordInputs._hasEmail,
"Has email rule is no longer detected"
@@ -128,26 +140,6 @@ add_task(async function password_validation() {
"Passwords are now considered valid"
);
- let classChangePromise = BrowserTestUtils.waitForMutationCondition(
- passwordRules,
- { attributes: true, attributesFilter: ["class"] },
- () => passwordRules.classList.contains("hidden")
- );
-
- /*
- * We can't use waitForMutationCondition because mutation observers do not detect computed style updates following a modified class name.
- * Plus, visibility changes are delayed due to transitions. Use waitForCondition instead to wait for the animation to finish and
- * validate the tooltip's final visibility state.
- */
- let hiddenPromise = BrowserTestUtils.waitForCondition(() => {
- return BrowserTestUtils.isHidden(passwordRules);
- });
-
- newPasswordInput.blur();
- await passwordInputs.updateComplete;
- await classChangePromise;
- await hiddenPromise;
-
Assert.ok(true, "Password rules tooltip should be hidden");
await SpecialPowers.popPrefEnv();
sandbox.restore();
diff --git a/browser/components/backup/tests/chrome/test_password_validation_inputs.html b/browser/components/backup/tests/chrome/test_password_validation_inputs.html
@@ -10,6 +10,8 @@
src="chrome://browser/content/backup/password-validation-inputs.mjs"
type="module"
></script>
+ <link rel="localization" href="browser/backupSettings.ftl"/>
+ <link rel="localization" href="branding/brand.ftl"/>
<link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
<script>