From 9e3ab0356627770a5a8e83a4655de00699a2964d Mon Sep 17 00:00:00 2001 From: web-padawan Date: Tue, 1 Oct 2024 11:12:45 +0300 Subject: [PATCH] fix: delay setting error message on focusout until click --- packages/login/package.json | 1 + packages/login/src/vaadin-lit-login-form.js | 4 +- packages/login/src/vaadin-login-form-mixin.js | 41 +++++++++++ packages/login/src/vaadin-login-form.js | 4 +- packages/login/test/login-form.common.js | 70 +++++++++++++++++++ 5 files changed, 116 insertions(+), 4 deletions(-) diff --git a/packages/login/package.json b/packages/login/package.json index bb26a551dd..86a2ce0ccf 100644 --- a/packages/login/package.json +++ b/packages/login/package.json @@ -37,6 +37,7 @@ "dependencies": { "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "24.6.0-alpha0", "@vaadin/button": "24.6.0-alpha0", "@vaadin/component-base": "24.6.0-alpha0", "@vaadin/overlay": "24.6.0-alpha0", diff --git a/packages/login/src/vaadin-lit-login-form.js b/packages/login/src/vaadin-lit-login-form.js index 9ed79d1343..bddb09ef57 100644 --- a/packages/login/src/vaadin-lit-login-form.js +++ b/packages/login/src/vaadin-lit-login-form.js @@ -56,10 +56,10 @@ class LoginForm extends LoginFormMixin(ElementMixin(ThemableMixin(PolylitMixin(L diff --git a/packages/login/src/vaadin-login-form-mixin.js b/packages/login/src/vaadin-login-form-mixin.js index 11611d42f7..f7eb8b3e67 100644 --- a/packages/login/src/vaadin-login-form-mixin.js +++ b/packages/login/src/vaadin-login-form-mixin.js @@ -3,6 +3,7 @@ * Copyright (c) 2018 - 2024 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ +import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js'; import { LoginMixin } from './vaadin-login-mixin.js'; /** @@ -46,6 +47,10 @@ export const LoginFormMixin = (superClass) => const userName = this.$.vaadinLoginUsername; const password = this.$.vaadinLoginPassword; + // Ensure both fields have error message + this.__setErrorMessage(userName); + this.__setErrorMessage(password); + if (this.disabled || !(userName.validate() && password.validate())) { return; } @@ -96,10 +101,42 @@ export const LoginFormMixin = (superClass) => } } + /** @private */ + __setErrorMessage(field, shouldClear) { + const errorKey = field.id === 'vaadinLoginUsername' ? 'username' : 'password'; + field.errorMessage = shouldClear ? '' : this.i18n.errorMessage[errorKey]; + } + + /** @protected */ + _handleInputFocusOut(event) { + const { currentTarget: field } = event; + + // Focus moved outside the browser tab, do nothing. + if (!document.hasFocus()) { + return; + } + + if (isKeyboardActive()) { + this.__setErrorMessage(field); + } else { + // Postpone setting error message until global click since setting it + // will affect field height and might affect click on other elements + // located below it, including the "forgot password" button. + document.addEventListener( + 'click', + () => { + this.__setErrorMessage(field); + }, + { once: true }, + ); + } + } + /** @protected */ _handleInputKeydown(e) { if (e.key === 'Enter') { const { currentTarget: inputActive } = e; + this.__setErrorMessage(inputActive); const nextInput = inputActive.id === 'vaadinLoginUsername' ? this.$.vaadinLoginPassword : this.$.vaadinLoginUsername; if (inputActive.validate()) { @@ -117,6 +154,10 @@ export const LoginFormMixin = (superClass) => const input = e.currentTarget; if (e.key === 'Tab' && input instanceof HTMLInputElement) { input.select(); + } else if (e.key !== 'Enter' && input.parentElement.errorMessage) { + // Reset error message when typing anything (including Backspace) + // to prevent click issues handled by the focusout event listener. + this.__setErrorMessage(input.parentElement, true); } } diff --git a/packages/login/src/vaadin-login-form.js b/packages/login/src/vaadin-login-form.js index d20b16c683..7eeef7a3b0 100644 --- a/packages/login/src/vaadin-login-form.js +++ b/packages/login/src/vaadin-login-form.js @@ -67,10 +67,10 @@ class LoginForm extends LoginFormMixin(ElementMixin(ThemableMixin(PolymerElement { const passwordField = login.$.vaadinLoginPassword; expect(passwordField.getAttribute('autocomplete')).to.be.equal('current-password'); }); + + describe('interactions', () => { + let forgotPassword; + + beforeEach(() => { + forgotPassword = login.querySelector('[slot="forgot-password"]'); + }); + + afterEach(async () => { + await resetMouse(); + }); + + ['username', 'password'].forEach((type) => { + describe(type, () => { + let field; + + beforeEach(async () => { + field = login.querySelector(`[name=${type}]`); + field.focus(); + await nextRender(); + }); + + it(`should handle forgot password click if ${type} field is focused and invalid`, async () => { + const spy = sinon.spy(); + login.addEventListener('forgot-password', spy); + + const { x, y } = forgotPassword.getBoundingClientRect(); + await sendMouse({ type: 'click', position: [Math.floor(x + 5), Math.floor(y + 5)] }); + await nextRender(); + + expect(spy).to.be.calledOnce; + }); + + it(`should set error message to ${type} field after the delay on click`, async () => { + const { x, y } = forgotPassword.getBoundingClientRect(); + await sendMouse({ type: 'move', position: [Math.floor(x + 5), Math.floor(y + 5)] }); + + await sendMouse({ type: 'down' }); + expect(field.errorMessage).to.be.not.ok; + + await sendMouse({ type: 'up' }); + expect(field.errorMessage).to.be.equal(login.i18n.errorMessage[type]); + }); + + it(`should set error message to ${type} field immediately on Tab`, async () => { + await sendKeys({ press: 'Tab' }); + expect(field.errorMessage).to.be.equal(login.i18n.errorMessage[type]); + }); + + it(`should set error message to ${type} field immediately on Enter`, async () => { + await sendKeys({ press: 'Enter' }); + expect(field.errorMessage).to.be.equal(login.i18n.errorMessage[type]); + }); + + it(`should reset error message to ${type} field after typing`, async () => { + await sendKeys({ type: 'a' }); + await sendKeys({ press: 'Backspace' }); + expect(field.errorMessage).to.be.not.ok; + }); + + it('should set error message to other field on form submit', () => { + const name = type === 'username' ? 'password' : 'username'; + const other = login.querySelector(`[name=${name}]`); + login.submit(); + expect(other.errorMessage).to.be.equal(login.i18n.errorMessage[name]); + }); + }); + }); + }); }); describe('no autofocus', () => {