diff --git a/.rubocop.yml b/.rubocop.yml index 5254c14c616..7efd5fdeaf8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -737,6 +737,12 @@ Rails/Blank: Rails/Delegate: Enabled: false +Rails/DynamicFindBy: + Include: + - tests/features/**/*.rb + AllowedMethods: + - find_by_id + Rails/FilePath: Enabled: false diff --git a/app/assets/stylesheets/components/_phone-input.scss b/app/assets/stylesheets/components/_phone-input.scss index 9e5a226da03..630b4de1911 100644 --- a/app/assets/stylesheets/components/_phone-input.scss +++ b/app/assets/stylesheets/components/_phone-input.scss @@ -1,3 +1,11 @@ +lg-phone-input { + display: block; + + .iti__flag { + background-image: image-url('intl-tel-input/build/img/flags.png'); + } +} + .phone-input__international-code-wrapper { display: none; @@ -6,8 +14,14 @@ } } -lg-phone-input .iti__flag { - background-image: image-url('intl-tel-input/build/img/flags.png'); +.iti:not(.iti--allow-dropdown) input { + padding-left: 36px; + padding-right: 6px; +} + +.iti:not(.iti--allow-dropdown) .iti__flag-container { + left: 0; + right: auto; } @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { diff --git a/app/assets/stylesheets/components/_validated-field.scss b/app/assets/stylesheets/components/_validated-field.scss new file mode 100644 index 00000000000..e2e306ee50c --- /dev/null +++ b/app/assets/stylesheets/components/_validated-field.scss @@ -0,0 +1,8 @@ +lg-validated-field { + display: block; + width: min-content; +} + +.validated-field__input-wrapper { + width: max-content; +} diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index 78864dcf600..a90df314012 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -25,3 +25,4 @@ @import 'step-indicator'; @import 'troubleshooting-options'; @import 'i18n-dropdown'; +@import 'validated-field'; diff --git a/app/components/accordion_component.html.erb b/app/components/accordion_component.html.erb index ffcbf829341..a7ab18f200f 100644 --- a/app/components/accordion_component.html.erb +++ b/app/components/accordion_component.html.erb @@ -4,12 +4,12 @@ type="button" class="usa-accordion__button" aria-expanded="false" - aria-controls="<%= @target_id %>" + aria-controls="accordion-<%= unique_id %>" > <%= header %> -
+
<%= content %>
diff --git a/app/components/accordion_component.rb b/app/components/accordion_component.rb index fdc84507e6d..4d971faef69 100644 --- a/app/components/accordion_component.rb +++ b/app/components/accordion_component.rb @@ -1,7 +1,3 @@ class AccordionComponent < BaseComponent renders_one :header - - def initialize - @target_id = "accordion-#{SecureRandom.hex(4)}" - end end diff --git a/app/components/base_component.rb b/app/components/base_component.rb index 85edfc12eb7..81ba0421ccb 100644 --- a/app/components/base_component.rb +++ b/app/components/base_component.rb @@ -10,4 +10,8 @@ def before_render def self.scripts @scripts ||= _sidecar_files(['js']).map { |file| File.basename(file, '.js') } end + + def unique_id + @unique_id ||= SecureRandom.hex(4) + end end diff --git a/app/components/phone_input_component.html.erb b/app/components/phone_input_component.html.erb index 12c8043c826..8f1b9fb6ca8 100644 --- a/app/components/phone_input_component.html.erb +++ b/app/components/phone_input_component.html.erb @@ -1,10 +1,11 @@ - +<%= content_tag( + 'lg-phone-input', + class: tag_options[:class], + data: { delivery_methods: delivery_methods }, + ) do %> <%= content_tag( :script, - { - country_code_label: t('components.phone_input.country_code_label'), - invalid_phone: t('errors.messages.improbable_phone'), - }.to_json, + strings.to_json, { type: 'application/json', class: 'phone-input__strings', @@ -35,15 +36,21 @@ <%= t('forms.example') %>
- <%= f.input( - :phone, + <%= render ValidatedFieldComponent.new( + form: f, + name: :phone, + error_messages: { + valueMissing: t('errors.messages.phone_required'), + }, as: :tel, required: required, label: false, + wrapper_html: { + class: 'margin-bottom-0', + }, input_html: { - aria: { invalid: false }, class: 'phone-input__number', }, ) %> - +<% end %> <%= stylesheet_link_tag 'intl-tel-input/build/css/intlTelInput' %> diff --git a/app/components/phone_input_component.rb b/app/components/phone_input_component.rb index 208dd93aa90..9828d65f33e 100644 --- a/app/components/phone_input_component.rb +++ b/app/components/phone_input_component.rb @@ -1,33 +1,66 @@ class PhoneInputComponent < BaseComponent - attr_reader :form, :required + attr_reader :form, :required, :allowed_countries, :delivery_methods, :tag_options + alias_method :f, :form - def initialize(form:, required: false) + def initialize( + form:, + allowed_countries: nil, + delivery_methods: [:sms, :voice], + required: false, + **tag_options + ) + @allowed_countries = allowed_countries @form = form @required = required + @delivery_methods = delivery_methods + @tag_options = tag_options end def supported_country_codes - PhoneNumberCapabilities::INTERNATIONAL_CODES.keys + codes = PhoneNumberCapabilities::INTERNATIONAL_CODES.keys + codes &= allowed_countries if allowed_countries + codes end def international_phone_codes - codes = PhoneNumberCapabilities::INTERNATIONAL_CODES.map do |key, value| - [ - international_phone_code_label(value), - key, - { data: international_phone_codes_data(value) }, - ] - end + supported_country_codes. + map do |code_key| + code_data = PhoneNumberCapabilities::INTERNATIONAL_CODES[code_key] + [ + international_phone_code_label(code_data), + code_key, + { data: international_phone_codes_data(code_data) }, + ] + end. + sort_by do |label, code_key, _data| + # Sort alphabetically by label, but put the US first in the list + [code_key == 'US' ? -1 : 1, label] + end + end - # Sort alphabetically by label, but put the US first in the list - codes.sort_by do |label, key, _data| - [key == 'US' ? -1 : 1, label] - end + def strings + { + country_code_label: t('components.phone_input.country_code_label'), + invalid_phone: t('errors.messages.invalid_phone_number'), + country_constraint_usa: t('errors.messages.phone_country_constraint_usa'), + unsupported_country: unsupported_country_string, + } end private + def unsupported_country_string + case delivery_methods.sort + when [:sms, :voice] + t('two_factor_authentication.otp_delivery_preference.no_supported_options') + when [:sms] + t('two_factor_authentication.otp_delivery_preference.sms_unsupported') + when [:voice] + t('two_factor_authentication.otp_delivery_preference.voice_unsupported') + end + end + def international_phone_code_label(code_data) "#{code_data['name']} +#{code_data['country_code']}" end diff --git a/app/components/validated_field_component.html.erb b/app/components/validated_field_component.html.erb new file mode 100644 index 00000000000..52944728e56 --- /dev/null +++ b/app/components/validated_field_component.html.erb @@ -0,0 +1,28 @@ + + <%= content_tag( + :script, + error_messages.to_json, + { + type: 'application/json', + class: 'validated-field__error-strings', + }, + false, + ) %> + <%= f.input( + name, + tag_options.deep_merge( + error: false, + wrapper_html: { + class: [*tag_options.dig(:wrapper_html, :class), 'validated-field__input-wrapper'], + }, + input_html: { + class: [*tag_options.dig(:input_html, :class), 'validated-field__input'], + aria: { + invalid: false, + describedby: "validated-field-error-#{unique_id}", + }, + }, + ), + ) %> + <%= f.error(name, id: "validated-field-error-#{unique_id}") %> + diff --git a/app/components/validated_field_component.js b/app/components/validated_field_component.js new file mode 100644 index 00000000000..c235c0826a1 --- /dev/null +++ b/app/components/validated_field_component.js @@ -0,0 +1,5 @@ +import { loadPolyfills } from '@18f/identity-polyfill'; + +loadPolyfills(['custom-elements', 'classlist']) + .then(() => import('@18f/identity-validated-field')) + .then(({ ValidatedField }) => customElements.define('lg-validated-field', ValidatedField)); diff --git a/app/components/validated_field_component.rb b/app/components/validated_field_component.rb new file mode 100644 index 00000000000..e5c5e9e4b4b --- /dev/null +++ b/app/components/validated_field_component.rb @@ -0,0 +1,19 @@ +class ValidatedFieldComponent < BaseComponent + attr_reader :form, :name, :tag_options + + alias_method :f, :form + + def initialize(form:, name:, error_messages: {}, **tag_options) + @form = form + @name = name + @error_messages = error_messages + @tag_options = tag_options + end + + def error_messages + { + valueMissing: t('simple_form.required.text'), + **@error_messages, + } + end +end diff --git a/app/forms/idv/phone_form.rb b/app/forms/idv/phone_form.rb index fbbcaa0c99d..9a747856935 100644 --- a/app/forms/idv/phone_form.rb +++ b/app/forms/idv/phone_form.rb @@ -4,7 +4,7 @@ class PhoneForm ALL_DELIVERY_METHODS = [:sms, :voice].freeze - attr_reader :user, :phone, :allowed_countries, :delivery_methods + attr_reader :user, :phone, :allowed_countries, :delivery_methods, :international_code validate :validate_valid_phone_for_allowed_countries validate :validate_phone_delivery_methods diff --git a/app/javascript/packages/eslint-plugin/configs/recommended.js b/app/javascript/packages/eslint-plugin/configs/recommended.js index f9f6beb90d5..9bca4ccca33 100644 --- a/app/javascript/packages/eslint-plugin/configs/recommended.js +++ b/app/javascript/packages/eslint-plugin/configs/recommended.js @@ -21,6 +21,7 @@ const config = { 'max-len': 'off', 'max-classes-per-file': 'off', 'newline-per-chained-call': 'off', + 'no-cond-assign': ['error', 'except-parens'], 'no-console': 'error', 'no-empty': ['error', { allowEmptyCatch: true }], 'no-param-reassign': ['off', 'never'], diff --git a/app/javascript/packages/phone-input/index.js b/app/javascript/packages/phone-input/index.js index bec088c0691..3d054072d41 100644 --- a/app/javascript/packages/phone-input/index.js +++ b/app/javascript/packages/phone-input/index.js @@ -1,12 +1,17 @@ -import { isValidNumber } from 'libphonenumber-js'; +import { isValidNumber, isValidNumberForRegion } from 'libphonenumber-js'; import 'intl-tel-input/build/js/utils.js'; import intlTelInput from 'intl-tel-input'; +import { replaceVariables } from '@18f/identity-i18n'; + +/** @typedef {import('libphonenumber-js').CountryCode} CountryCode */ /** * @typedef PhoneInputStrings * * @prop {string=} country_code_label * @prop {string=} invalid_phone + * @prop {string=} country_constraint_usa + * @prop {string=} unsupported_country */ /** @@ -20,8 +25,6 @@ const { intlTelInputUtils, } = /** @type {window & { intlTelInputUtils: IntlTelInputUtilsGlobal }} */ (window); -const INTERNATIONAL_CODE_REGEX = /^\+(\d+) |^1 /; - const isPhoneValid = (phone, countryCode) => { let phoneValid = isValidNumber(phone, countryCode); if (!phoneValid && countryCode === 'US') { @@ -30,14 +33,6 @@ const isPhoneValid = (phone, countryCode) => { return phoneValid; }; -const internationalCodeFromPhone = (phone) => { - const match = phone.match(INTERNATIONAL_CODE_REGEX); - if (match) { - return match[1] || match[2]; - } - return '1'; -}; - const updateInternationalCodeInPhone = (phone, newCode) => phone.replace(new RegExp(`^\\+?(\\d+\\s+|${newCode})?`), `+${newCode} `); @@ -45,6 +40,9 @@ export class PhoneInput extends HTMLElement { /** @type {PhoneInputStrings} */ #_strings; + /** @type {string[]} */ + deliveryMethods = []; + connectedCallback() { /** @type {HTMLInputElement?} */ this.textInput = this.querySelector('.phone-input__number'); @@ -52,6 +50,9 @@ export class PhoneInput extends HTMLElement { this.codeInput = this.querySelector('.phone-input__international-code'); this.codeWrapper = this.querySelector('.phone-input__international-code-wrapper'); this.exampleText = this.querySelector('.phone-input__example'); + try { + this.deliveryMethods = JSON.parse(this.dataset.deliveryMethods || ''); + } catch {} if (!this.textInput || !this.codeInput) { return; @@ -119,42 +120,79 @@ export class PhoneInput extends HTMLElement { initializeIntlTelInput() { const { supportedCountryCodes } = this; + const allowDropdown = supportedCountryCodes && supportedCountryCodes.length > 1; const iti = intlTelInput(this.textInput, { preferredCountries: ['US', 'CA'], onlyCountries: supportedCountryCodes, autoPlaceholder: 'off', + allowDropdown, }); - // Remove duplicate items in the country list - /** @type {NodeListOf} */ - const preferred = iti.countryList.querySelectorAll('.iti__preferred'); - preferred.forEach((listItem) => { - const { countryCode } = listItem.dataset; + if (allowDropdown) { + // Remove duplicate items in the country list /** @type {NodeListOf} */ - const duplicates = iti.countryList.querySelectorAll( - `.iti__standard[data-country-code="${countryCode}"]`, - ); - duplicates.forEach((duplicateListItem) => { - duplicateListItem.parentNode?.removeChild(duplicateListItem); + const preferred = iti.countryList.querySelectorAll('.iti__preferred'); + preferred.forEach((listItem) => { + const { countryCode } = listItem.dataset; + /** @type {NodeListOf} */ + const duplicates = iti.countryList.querySelectorAll( + `.iti__standard[data-country-code="${countryCode}"]`, + ); + duplicates.forEach((duplicateListItem) => { + duplicateListItem.parentNode?.removeChild(duplicateListItem); + }); }); - }); - // Improve base accessibility of intl-tel-input - iti.flagsContainer.setAttribute('aria-label', this.strings.country_code_label); - iti.selectedFlag.setAttribute('aria-haspopup', 'true'); - iti.selectedFlag.setAttribute('role', 'button'); - iti.selectedFlag.removeAttribute('aria-owns'); + // Improve base accessibility of intl-tel-input + iti.flagsContainer.setAttribute('aria-label', this.strings.country_code_label); + iti.selectedFlag.setAttribute('aria-haspopup', 'true'); + iti.selectedFlag.setAttribute('role', 'button'); + iti.selectedFlag.removeAttribute('aria-owns'); + } return iti; } validate() { - const { textInput, codeInput } = this; - if (textInput && codeInput) { - const isValid = isPhoneValid(textInput.value, codeInput.value); - const validity = (!isValid && this.strings.invalid_phone) || ''; - textInput.setCustomValidity(validity); + const { textInput, codeInput, supportedCountryCodes, selectedOption } = this; + if (!textInput || !codeInput || !selectedOption) { + return; + } + + const phoneNumber = textInput.value; + const countryCode = /** @type {CountryCode} */ (codeInput.value); + + textInput.setCustomValidity(''); + if (!phoneNumber) { + return; + } + + const isInvalidCountry = + supportedCountryCodes?.length === 1 && !isValidNumberForRegion(phoneNumber, countryCode); + if (isInvalidCountry) { + if (countryCode === 'US') { + textInput.setCustomValidity(this.strings.country_constraint_usa || ''); + } else { + textInput.setCustomValidity(this.strings.invalid_phone || ''); + } + } + + const isInvalidPhoneNumber = !isPhoneValid(phoneNumber, countryCode); + if (isInvalidPhoneNumber) { + textInput.setCustomValidity(this.strings.invalid_phone || ''); + } + + if (!this.isSupportedCountry()) { + const validationMessage = replaceVariables(this.strings.unsupported_country || '', { + location: selectedOption.dataset.countryName, + }); + + textInput.setCustomValidity(validationMessage); + + // While most other validations can wait 'til submission to present user feedback, this one + // should notify immediately. + textInput.dispatchEvent(new CustomEvent('invalid')); } } @@ -165,12 +203,9 @@ export class PhoneInput extends HTMLElement { } const phone = textInput.value; - const inputInternationalCode = internationalCodeFromPhone(phone); const selectedInternationalCode = selectedOption.dataset.countryCode; - - if (inputInternationalCode !== selectedInternationalCode) { - textInput.value = updateInternationalCodeInPhone(phone, selectedInternationalCode); - } + textInput.value = updateInternationalCodeInPhone(phone, selectedInternationalCode); + textInput.dispatchEvent(new CustomEvent('input', { bubbles: true })); } /** @@ -185,6 +220,16 @@ export class PhoneInput extends HTMLElement { return !!selectedOption && selectedOption.getAttribute(`data-supports-${delivery}`) !== 'false'; } + /** + * Returns true if the currently selected country can receive a supported delivery options, or + * false otherwise. + * + * @return {boolean} Whether selected country is supported. + */ + isSupportedCountry() { + return this.deliveryMethods.some((delivery) => this.isDeliveryOptionSupported(delivery)); + } + setExampleNumber() { const { exampleText, iti } = this; const { iso2 = 'us' } = iti.selectedCountryData; diff --git a/app/javascript/packages/phone-input/index.spec.js b/app/javascript/packages/phone-input/index.spec.js new file mode 100644 index 00000000000..1b21dc59440 --- /dev/null +++ b/app/javascript/packages/phone-input/index.spec.js @@ -0,0 +1,168 @@ +import { getByLabelText } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; + +const MULTIPLE_OPTIONS_HTML = ` + `; + +const SINGLE_OPTION_HTML = ` + `; + +const SINGLE_OPTION_SELECT_NON_US_HTML = ` + `; + +describe('PhoneInput', () => { + before(async () => { + await import('intl-tel-input/build/js/utils.js'); + window.intlTelInputUtils = global.intlTelInputUtils; + const { PhoneInput } = await import('./index.js'); + customElements.define('lg-phone-input', PhoneInput); + }); + + function createAndConnectElement({ + isSingleOption = false, + isNonUSSingleOption = false, + deliveryMethods = ['sms', 'voice'], + } = {}) { + const element = document.createElement('lg-phone-input'); + element.setAttribute('data-delivery-methods', JSON.stringify(deliveryMethods)); + element.innerHTML = ` + +
+ + ${isSingleOption ? SINGLE_OPTION_HTML : ''} + ${isNonUSSingleOption ? SINGLE_OPTION_SELECT_NON_US_HTML : ''} + ${!isSingleOption && !isNonUSSingleOption ? MULTIPLE_OPTIONS_HTML : ''} +
+ +
+ Example: + +
+ + + + + `; + + document.body.appendChild(element); + + return element; + } + + it('initializes with dropdown', () => { + const input = createAndConnectElement(); + + expect(input.querySelector('.iti.iti--allow-dropdown')).to.be.ok(); + }); + + it('validates input', () => { + const input = createAndConnectElement(); + + /** @type {HTMLInputElement} */ + const phoneNumber = getByLabelText(input, 'Phone number'); + + expect(phoneNumber.validity.valueMissing).to.be.true(); + + userEvent.type(phoneNumber, '5'); + expect(phoneNumber.validationMessage).to.equal('Phone number is not valid'); + + userEvent.type(phoneNumber, '13-555-1234'); + expect(phoneNumber.validity.valid).to.be.true(); + }); + + it('validates supported delivery method', () => { + const input = createAndConnectElement(); + + /** @type {HTMLInputElement} */ + const phoneNumber = getByLabelText(input, 'Phone number'); + /** @type {HTMLSelectElement} */ + const countryCode = getByLabelText(input, 'Country code', { selector: 'select' }); + + userEvent.selectOptions(countryCode, 'LK'); + expect(phoneNumber.validationMessage).to.equal( + 'We are unable to verify phone numbers from Sri Lanka', + ); + }); + + it('formats on country change', () => { + const input = createAndConnectElement(); + + /** @type {HTMLInputElement} */ + const phoneNumber = getByLabelText(input, 'Phone number'); + /** @type {HTMLSelectElement} */ + const countryCode = getByLabelText(input, 'Country code', { selector: 'select' }); + + userEvent.type(phoneNumber, '071'); + + userEvent.selectOptions(countryCode, 'LK'); + expect(phoneNumber.value).to.equal('+94 071'); + + userEvent.selectOptions(countryCode, 'US'); + expect(phoneNumber.value).to.equal('+1 071'); + }); + + context('with single option', () => { + it('initializes without dropdown', () => { + const input = createAndConnectElement({ isSingleOption: true }); + + expect(input.querySelector('.iti:not(.iti--allow-dropdown)')).to.be.ok(); + }); + + it('validates phone from region', () => { + const input = createAndConnectElement({ isSingleOption: true }); + + /** @type {HTMLInputElement} */ + const phoneNumber = getByLabelText(input, 'Phone number'); + + userEvent.type(phoneNumber, '306-555-1234'); + expect(phoneNumber.validationMessage).to.equal('Must be a U.S. phone number'); + }); + + context('with non-U.S. single option', () => { + it('validates phone from region', () => { + const input = createAndConnectElement({ isNonUSSingleOption: true }); + + /** @type {HTMLInputElement} */ + const phoneNumber = getByLabelText(input, 'Phone number'); + + userEvent.type(phoneNumber, '513-555-1234'); + expect(phoneNumber.validationMessage).to.equal('Phone number is not valid'); + }); + }); + }); + + context('with constrained delivery options', () => { + it('validates supported delivery method', () => { + const input = createAndConnectElement({ deliveryMethods: ['voice'] }); + + /** @type {HTMLInputElement} */ + const phoneNumber = getByLabelText(input, 'Phone number'); + /** @type {HTMLSelectElement} */ + const countryCode = getByLabelText(input, 'Country code', { selector: 'select' }); + + userEvent.selectOptions(countryCode, 'CA'); + expect(phoneNumber.validationMessage).to.equal( + 'We are unable to verify phone numbers from Canada', + ); + }); + }); +}); diff --git a/app/javascript/packages/validated-field/index.js b/app/javascript/packages/validated-field/index.js new file mode 100644 index 00000000000..dc9e4f10120 --- /dev/null +++ b/app/javascript/packages/validated-field/index.js @@ -0,0 +1,90 @@ +export class ValidatedField extends HTMLElement { + /** @type {Partial} */ + errorStrings = {}; + + connectedCallback() { + /** @type {HTMLInputElement?} */ + this.input = this.querySelector('.validated-field__input'); + this.errorMessage = this.querySelector('.usa-error-message'); + this.descriptorId = this.input?.getAttribute('aria-describedby'); + try { + Object.assign( + this.errorStrings, + JSON.parse(this.querySelector('.validated-field__error-strings')?.textContent || ''), + ); + } catch {} + + this.input?.addEventListener('input', () => this.setErrorMessage()); + this.input?.addEventListener('invalid', (event) => this.toggleErrorMessage(event)); + } + + /** + * Handles an invalid event, rendering or hiding an error message based on the input's current + * validity. + * + * @param {Event} event Invalid event. + */ + toggleErrorMessage(event) { + event.preventDefault(); + this.setErrorMessage(this.getNormalizedValidationMessage(this.input)); + } + + /** + * Renders the given message as an error, if present. Otherwise, hides any visible error message. + * + * @param {string?=} message Error message to show, or empty to hide. + */ + setErrorMessage(message) { + if (message) { + this.getOrCreateErrorMessageElement().textContent = message; + this.input?.focus(); + } else if (this.errorMessage) { + this.removeChild(this.errorMessage); + this.errorMessage = null; + } + + this.input?.classList.toggle('usa-input--error', !!message); + } + + /** + * Returns a validation message for the given input, normalized to use customized error strings. + * An empty string is returned for a valid input. + * + * @param {HTMLInputElement?=} input Input element. + * + * @return {string} Validation message. + */ + getNormalizedValidationMessage(input) { + if (!input || input.validity.valid) { + return ''; + } + + for (const type in input.validity) { + if (type !== 'valid' && input.validity[type] && this.errorStrings[type]) { + return this.errorStrings[type]; + } + } + + return input.validationMessage; + } + + /** + * Returns an error message element. If one doesn't already exist, it is created and appended to + * the root. + * + * @returns {Element} Error message element. + */ + getOrCreateErrorMessageElement() { + if (!this.errorMessage) { + this.errorMessage = this.ownerDocument.createElement('div'); + this.errorMessage.classList.add('usa-error-message'); + if (this.descriptorId) { + this.errorMessage.id = this.descriptorId; + } + + this.appendChild(this.errorMessage); + } + + return this.errorMessage; + } +} diff --git a/app/javascript/packages/validated-field/index.spec.js b/app/javascript/packages/validated-field/index.spec.js new file mode 100644 index 00000000000..f92306de296 --- /dev/null +++ b/app/javascript/packages/validated-field/index.spec.js @@ -0,0 +1,104 @@ +import { getByRole, getByText } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { ValidatedField } from '.'; + +describe('ValidatedField', () => { + before(() => { + customElements.define('lg-validated-field', ValidatedField); + }); + + function createAndConnectElement({ hasInitialError = false } = {}) { + const element = document.createElement('lg-validated-field'); + element.innerHTML = ` + + + ${ + hasInitialError + ? '
Invalid value
' + : '' + } + `; + + const form = document.createElement('form'); + form.appendChild(element); + document.body.appendChild(form); + + return element; + } + + it('shows error state and focuses on form validation', () => { + const element = createAndConnectElement(); + + /** @type {HTMLInputElement} */ + const input = getByRole(element, 'textbox'); + + /** @type {HTMLFormElement} */ + const form = element.parentNode; + form.checkValidity(); + + expect(input.classList.contains('usa-input--error')).to.be.true(); + expect(document.activeElement).to.equal(input); + const message = getByText(element, 'This field is required'); + expect(message).to.be.ok(); + expect(message.id).to.equal(input.getAttribute('aria-describedby')); + }); + + it('shows custom validity as message content', () => { + const element = createAndConnectElement(); + + /** @type {HTMLInputElement} */ + const input = getByRole(element, 'textbox'); + input.value = 'a'; + input.setCustomValidity('custom validity'); + + /** @type {HTMLFormElement} */ + const form = element.parentNode; + form.checkValidity(); + + expect(getByText(element, 'custom validity')).to.be.ok(); + }); + + it('clears existing validation state on input', () => { + const element = createAndConnectElement(); + + /** @type {HTMLInputElement} */ + const input = getByRole(element, 'textbox'); + + /** @type {HTMLFormElement} */ + const form = element.parentNode; + form.checkValidity(); + + userEvent.type(input, '5'); + + expect(input.classList.contains('usa-input--error')).to.be.false(); + expect(() => getByText(element, 'This field is required')).to.throw(); + }); + + context('with initial error message', () => { + it('clears existing validation state on input', () => { + const element = createAndConnectElement(); + + /** @type {HTMLInputElement} */ + const input = getByRole(element, 'textbox'); + + /** @type {HTMLFormElement} */ + const form = element.parentNode; + form.checkValidity(); + + userEvent.type(input, '5'); + + expect(input.classList.contains('usa-input--error')).to.be.false(); + expect(() => getByText(element, 'Invalid value')).to.throw(); + }); + }); +}); diff --git a/app/javascript/packages/validated-field/package.json b/app/javascript/packages/validated-field/package.json new file mode 100644 index 00000000000..4838734149e --- /dev/null +++ b/app/javascript/packages/validated-field/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-validated-field", + "private": true, + "version": "1.0.0" +} diff --git a/app/javascript/packs/otp-delivery-preference.js b/app/javascript/packs/otp-delivery-preference.js index afb3ab5f1c3..bc4feb706fb 100644 --- a/app/javascript/packs/otp-delivery-preference.js +++ b/app/javascript/packs/otp-delivery-preference.js @@ -8,6 +8,14 @@ const { t } = /** @type {GlobalWithLoginGov} */ (window).LoginGov.I18n; +/** + * Returns the OTP delivery preference element. + * + * @return {HTMLElement} + */ +const getOTPDeliveryMethodContainer = () => + /** @type {HTMLElement} */ (document.querySelector('.js-otp-delivery-preferences')); + /** * @return {HTMLInputElement[]} */ @@ -24,12 +32,6 @@ const getOTPDeliveryMethods = () => const isDeliveryOptionSupported = (delivery, selectedOption) => selectedOption.getAttribute(`data-supports-${delivery}`) !== 'false'; -/** - * @param {HTMLFormElement} form - * @return {HTMLButtonElement|HTMLInputElement|null} - */ -const getSubmitButton = (form) => form.querySelector('button:not([type]),[type="submit"]'); - /** * @param {string} delivery * @param {string} location @@ -68,6 +70,14 @@ const isAllDisabled = (inputs) => inputs.every((input) => input.disabled); */ const getFirstEnabledInput = (inputs) => inputs.find((input) => !input.disabled); +/** + * Toggles the delivery preferences selection visible or hidden. + * + * @param {boolean} isVisible Whether the selection element should be visible. + */ +const toggleDeliveryPreferencesVisible = (isVisible) => + getOTPDeliveryMethodContainer().classList.toggle('display-none', !isVisible); + /** * @param {Event} event */ @@ -106,43 +116,10 @@ function updateOTPDeliveryMethods(event) { }); const isAllMethodsDisabled = isAllDisabled(methods); - const hintText = t('two_factor_authentication.otp_delivery_preference.no_supported_options', { - location, - }); - if (isAllMethodsDisabled) { - setHintText(hintText); - select.setCustomValidity(hintText); - select.reportValidity(); - } else if (!select.validity.valid) { - select.setCustomValidity(''); - select.reportValidity(); - } + toggleDeliveryPreferencesVisible(!isAllMethodsDisabled); } document.querySelectorAll('lg-phone-input').forEach((node) => { const phoneInput = /** @type {PhoneInput} */ (node); - const form = /** @type {HTMLFormElement} */ (phoneInput.closest('form')); - - function setSubmitDisabled(isDisabled) { - const submitButton = getSubmitButton(form); - if (submitButton && submitButton.disabled !== isDisabled) { - submitButton.disabled = isDisabled; - } - } - - phoneInput.addEventListener('input', () => setSubmitDisabled(!form.checkValidity())); - phoneInput.addEventListener('change', (event) => { - setSubmitDisabled(!form.checkValidity()); - updateOTPDeliveryMethods(event); - }); - phoneInput.addEventListener( - 'invalid', - (event) => { - setSubmitDisabled(true); - event.preventDefault(); - }, - true, - ); - - setSubmitDisabled(!form.checkValidity()); + phoneInput.addEventListener('change', updateOTPDeliveryMethods); }); diff --git a/app/views/idv/doc_auth/send_link.html.erb b/app/views/idv/doc_auth/send_link.html.erb index a2191f476f0..769b7575bff 100644 --- a/app/views/idv/doc_auth/send_link.html.erb +++ b/app/views/idv/doc_auth/send_link.html.erb @@ -23,7 +23,12 @@ method: 'PUT', html: { autocomplete: 'off', class: 'margin-top-4' }, ) do |f| %> - <%= render PhoneInputComponent.new(form: f, required: true) %> + <%= render PhoneInputComponent.new( + form: f, + required: true, + delivery_methods: [:sms], + class: 'margin-bottom-4', + ) %>