tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mbrowser/components/backup/content/password-rules-tooltip.css | 6+++---
Mbrowser/components/backup/content/password-rules-tooltip.mjs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbrowser/components/backup/content/password-validation-inputs.css | 11+++++++++++
Mbrowser/components/backup/content/password-validation-inputs.mjs | 107++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mbrowser/components/backup/tests/browser/browser_password_validation_inputs.js | 32++++++++++++--------------------
Mbrowser/components/backup/tests/chrome/test_password_validation_inputs.html | 2++
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>