From 2330399930934db2fc7158aa22b08988bbd71eac Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Fri, 5 May 2023 18:41:24 -0700 Subject: [PATCH] Fix: localized numeral entry by keyboard in number input widgets (#973) --- src/widget/number-input/decimal-input.js | 30 ++--- src/widget/number-input/integer-input.js | 39 ++++++- src/widget/number-input/number-input.js | 6 +- test/spec/widget.number-input.spec.js | 137 +++++++++++++++++------ 4 files changed, 156 insertions(+), 56 deletions(-) diff --git a/src/widget/number-input/decimal-input.js b/src/widget/number-input/decimal-input.js index fef30f00..cc99a573 100644 --- a/src/widget/number-input/decimal-input.js +++ b/src/widget/number-input/decimal-input.js @@ -27,28 +27,28 @@ const getDecimalCharacters = (languages) => { return characters; }; -/** @type {Map} */ -let characterPatternsByLanguage = new Map(); +/** @type {Map>} */ +let validCharactersByLanguage = new Map(); /** * @param {string[]} languages */ -const getCharacterPattern = (languages) => { +const getValidCharacters = (languages) => { const [language] = languages; - let characterPattern = characterPatternsByLanguage.get(language); + let validCharacters = validCharactersByLanguage.get(language); - if (characterPattern != null) { - return characterPattern; + if (validCharacters != null) { + return validCharacters; } - const decimalCharacters = getDecimalCharacters(language); + const locales = Intl.getCanonicalLocales(languages); + const formatter = Intl.NumberFormat(locales); + const number = -9.012345678; - characterPattern = new RegExp( - `[-0-9${Array.from(decimalCharacters).join('')}]` - ); - characterPatternsByLanguage.set(language, characterPattern); + validCharacters = new Set(`${number}${formatter.format(number)}`.split('')); + validCharactersByLanguage.set(language, validCharacters); - return characterPattern; + return validCharacters; }; /** @type {Map} */ @@ -92,7 +92,7 @@ export default class DecimalInput extends NumberInput { */ static globalReset(form, rootElement) { decimalCharactersByLanguage = new Map(); - characterPatternsByLanguage = new Map(); + validCharactersByLanguage = new Map(); validityPatternsByLanguage = new Map(); super.globalReset(form, rootElement); } @@ -103,8 +103,8 @@ export default class DecimalInput extends NumberInput { return getDecimalCharacters(this.languages); } - static get characterPattern() { - return getCharacterPattern(this.languages); + static get validCharacters() { + return getValidCharacters(this.languages); } static get pattern() { diff --git a/src/widget/number-input/integer-input.js b/src/widget/number-input/integer-input.js index 8f7e77d2..30a92f3a 100644 --- a/src/widget/number-input/integer-input.js +++ b/src/widget/number-input/integer-input.js @@ -1,8 +1,32 @@ import NumberInput from './number-input'; +/** @type {Map>} */ +let validCharactersByLanguage = new Map(); + +/** + * @param {string[]} languages + */ +const getValidCharacters = (languages) => { + const [language] = languages; + let validCharacters = validCharactersByLanguage.get(language); + + if (validCharacters != null) { + return validCharacters; + } + + const locales = Intl.getCanonicalLocales(languages); + const formatter = Intl.NumberFormat(locales); + const number = -9012345678; + + validCharacters = new Set(`${number}${formatter.format(number)}`.split('')); + validCharactersByLanguage.set(language, validCharacters); + + return validCharacters; +}; + export default class IntegerInput extends NumberInput { /** - * @param {import('./form').Form} form + * @param {import('../../js/form').Form} form * @param {HTMLFormElement} rootElement */ static globalInit(form, rootElement) { @@ -10,9 +34,20 @@ export default class IntegerInput extends NumberInput { super.globalInit(form, rootElement); } + /** + * @param {import('./form').Form} form + * @param {HTMLFormElement} rootElement + */ + static globalReset(form, rootElement) { + validCharactersByLanguage = new Map(); + super.globalReset(form, rootElement); + } + static selector = '.question input[type="number"][data-type-xml="int"]'; - static characterPattern = /[-0-9]/; + static get validCharacters() { + return getValidCharacters(this.languages); + } static pattern = /^-?[0-9]+$/; diff --git a/src/widget/number-input/number-input.js b/src/widget/number-input/number-input.js index 4ae1367a..17cf8ee6 100644 --- a/src/widget/number-input/number-input.js +++ b/src/widget/number-input/number-input.js @@ -164,9 +164,9 @@ class NumberInput extends Widget { /** * @abstract - * @type {RegExp} + * @type {Set} */ - static get characterPattern() { + static get validCharacters() { throw new Error('Not implemented'); } @@ -210,7 +210,7 @@ class NumberInput extends Widget { metaKey || (key.length > 1 && key !== 'Spacebar') || (!isComposing && - this.constructor.characterPattern.test(event.key)) + this.constructor.validCharacters.has(event.key)) ) { return true; } diff --git a/test/spec/widget.number-input.spec.js b/test/spec/widget.number-input.spec.js index baa60391..b56707d4 100644 --- a/test/spec/widget.number-input.spec.js +++ b/test/spec/widget.number-input.spec.js @@ -18,6 +18,12 @@ describe('Number inputs', () => { /** @type {Form} */ let form; + /** @type {HTMLElement | null} */ + let formHeader; + + /** @type {HTMLSelectElement | null} */ + let languageSelect; + /** @type {HTMLFormElement} */ let formElement; @@ -38,6 +44,8 @@ describe('Number inputs', () => { }); beforeEach(() => { + formHeader = null; + languageSelect = null; decimalInput = null; integerInput = null; @@ -65,6 +73,7 @@ describe('Number inputs', () => { }); afterEach(() => { + formHeader?.remove(); formElement.remove(); sandbox.restore(); form.resetView(); @@ -371,6 +380,96 @@ describe('Number inputs', () => { expect(question.classList.contains('invalid-value')).to.equal(true); }); + /** + * @param {string[]} languages + */ + const setLanguageOptions = (languages) => { + const languageOptions = languages.map((language) => { + const option = document.createElement('option'); + + option.value = language; + + return option; + }); + + formHeader = document.createElement('header'); + + formHeader.classList.add('form-header'); + + const languageSelectContainer = document.createElement('span'); + + languageSelectContainer.classList.add('form-language-selector'); + formHeader.append(languageSelectContainer); + formElement.insertAdjacentElement('beforebegin', formHeader); + + languageSelect = document.createElement('select'); + languageSelect.id = 'form-languages'; + languageSelectContainer.append(languageSelect); + languageSelect.append(...languageOptions); + formElement.append(languageSelect); + }; + + const isLocalizedNumeralInputSupported = () => { + const label = document.createElement('label'); + const input = document.createElement('input'); + + label.append(input); + + label.lang = 'ar-EG'; + input.type = 'number'; + input.value = '١'; + + return input.value !== '' && input.checkValidity(); + }; + + if (isLocalizedNumeralInputSupported()) { + describe('localized integer input', () => { + before(() => { + preInit = false; + }); + + beforeEach(() => { + setLanguageOptions(['ar-EG']); + form.init(); + formElement.dispatchEvent(events.ChangeLanguage()); + }); + + it('does not prevent entering localized numerals in an integer input by keyboard', () => { + const control = formElement.querySelector( + IntegerInput.selector + ); + const event = new KeyboardEvent('keydown', { + key: '١', + }); + const preventDefaultStub = sandbox.stub( + event, + 'preventDefault' + ); + + control.dispatchEvent(event); + + expect(preventDefaultStub).not.to.have.been.called; + }); + + it('does not prevent entering localized numerals in a decimal input by keyboard', () => { + const control = formElement.querySelector( + IntegerInput.selector + ); + const event = new KeyboardEvent('keydown', { + key: '٢', + }); + const preventDefaultStub = sandbox.stub( + event, + 'preventDefault' + ); + + control.dispatchEvent(event); + + expect(preventDefaultStub).not.to.have.been.called; + }); + }); + } + const isLocalizedDecimalInputSupported = () => { const label = document.createElement('label'); const input = document.createElement('input'); @@ -441,12 +540,6 @@ describe('Number inputs', () => { 'zh-HK', ]; - /** @type {HTMLElement} */ - let formHeader; - - /** @type {HTMLSelectElement} */ - let languageSelect; - before(() => { preInit = false; }); @@ -454,38 +547,10 @@ describe('Number inputs', () => { beforeEach(() => { formElement = form.view.html; - const languageOptions = [ + setLanguageOptions([ unsupportedLanguage, ...supportedLanguages, - ].map((language) => { - const option = document.createElement('option'); - - option.value = language; - - return option; - }); - - formHeader = document.createElement('header'); - - formHeader.classList.add('form-header'); - - const languageSelectContainer = - document.createElement('span'); - - languageSelectContainer.classList.add( - 'form-language-selector' - ); - formHeader.append(languageSelectContainer); - formElement.insertAdjacentElement( - 'beforebegin', - formHeader - ); - - languageSelect = document.createElement('select'); - languageSelect.id = 'form-languages'; - languageSelectContainer.append(languageSelect); - languageSelect.append(...languageOptions); - formElement.append(languageSelect); + ]); }); afterEach(() => {