From e95ef1f250f34cdecfa7e8d4f15c0a97471020e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Tue, 19 Dec 2023 17:41:55 +0100 Subject: [PATCH] feat(InputMasked): enhance inputMode for better support of showing correct soft keyboards (#3108) For iOS we use a custom trick (me and Joakim found during a peer prog session). This should solve #3097 Co-authored-by: Joakim Bjerknes --- .../uilib/components/input-masked/info.mdx | 6 +- .../input-masked/InputMaskedHooks.js | 14 +- .../input-masked/InputMaskedUtils.js | 24 +- .../src/components/input-masked/TextMask.js | 16 +- .../__tests__/InputMasked.test.tsx | 353 ++++++++++++++---- .../__snapshots__/InputMasked.test.tsx.snap | 3 + .../stories/InputMasked.stories.tsx | 4 + .../input-masked/style/dnb-input-masked.scss | 5 + .../input-masked/text-mask/InputModeNumber.ts | 107 ++++++ .../text-mask/createTextMaskInputElement.js | 2 +- 10 files changed, 438 insertions(+), 96 deletions(-) create mode 100644 packages/dnb-eufemia/src/components/input-masked/text-mask/InputModeNumber.ts diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/input-masked/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/input-masked/info.mdx index 457e65bd9bb..c1fd9f8db91 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/input-masked/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/input-masked/info.mdx @@ -30,7 +30,11 @@ Both entering a comma or a dot will act as a decimal separator if [decimals are #### InputMode -For mobile devices and soft keyboards, the HTML input element does support a numeric-only keyboard. But sadly it does not support negative values at the time of writing this. So it is only enabled if `allowNegative` is set to false. +**NB:** Please do not set `inputMode="numeric"` or `inputMode="decimal"`, because devices may or may not show a minus key (`-`)! + +The InputMasked component does handle soft keyboards (iOS and Android) by using either `inputMode="numeric"` and `inputMode="decimal"` depending on `allowNegative` and `allowDecimal` (getSoftKeyboardAttributes). + +For iOS it additionally sets `type="number"` during focus (InputModeNumber). This way the correct numeric soft keyboard is shown. diff --git a/packages/dnb-eufemia/src/components/input-masked/InputMaskedHooks.js b/packages/dnb-eufemia/src/components/input-masked/InputMaskedHooks.js index 4d7096e0112..bbbb4395f53 100644 --- a/packages/dnb-eufemia/src/components/input-masked/InputMaskedHooks.js +++ b/packages/dnb-eufemia/src/components/input-masked/InputMaskedHooks.js @@ -29,7 +29,7 @@ import { handleCurrencyMask, handleNumberMask, correctCaretPosition, - getInputModeFromMask, + getSoftKeyboardAttributes, handleThousandsSeparator, handleDecimalSeparator, fromJSON, @@ -230,11 +230,6 @@ export const useInputElement = () => { // Set ref for Eufemia input innerRef.current = ref.current - // Set "inputMode" - if (!params.inputMode) { - params.inputMode = getInputModeFromMask(mask) - } - return ( { guide={showGuide} keepCharPositions={keepCharPositions} placeholderChar={placeholderChar} + {...getSoftKeyboardAttributes(mask)} {...params} className={classnames( params.className, @@ -441,7 +437,11 @@ const useCallEvent = ({ setLocalValue }) => { !props.selectall ) { // Also correct here, because of additional click inside the field - correctCaretPosition(event.target, maskParams, props) + event.target.runCorrectCaretPosition = () => + correctCaretPosition(event.target, maskParams, props) + if (!event.target.__getCorrectCaretPosition) { + event.target.runCorrectCaretPosition() + } } return result diff --git a/packages/dnb-eufemia/src/components/input-masked/InputMaskedUtils.js b/packages/dnb-eufemia/src/components/input-masked/InputMaskedUtils.js index 32e460fa253..eb041f225b1 100644 --- a/packages/dnb-eufemia/src/components/input-masked/InputMaskedUtils.js +++ b/packages/dnb-eufemia/src/components/input-masked/InputMaskedUtils.js @@ -9,7 +9,7 @@ import { getThousandsSeparator, } from '../number-format/NumberUtils' import { warn } from '../../shared/component-helper' -import { IS_ANDROID, IS_IOS } from '../../shared/helpers' +import { IS_IOS } from '../../shared/helpers' import { safeSetSelection } from './text-mask/createTextMaskInputElement' const enableLocaleSupportWhen = ['as_number', 'as_percent', 'as_currency'] @@ -332,25 +332,29 @@ export const handleNumberMask = ({ mask_options, number_mask }) => { } /** - * Returns the type of what inputMode should be used + * Returns the type of what inputMode or type attribute should be used * * @param {function} mask mask function * @returns undefined|decimal|numeric */ -export function getInputModeFromMask(mask) { +export function getSoftKeyboardAttributes(mask) { + if (mask?.instanceOf !== 'createNumberMask') { + return undefined + } + const maskParams = mask?.maskParams - // because of the missing minus key, we still have to use text on Android and iOS - if ((IS_ANDROID || IS_IOS) && maskParams?.allowNegative !== false) { + // because of the missing minus key, we still have to use text on iOS + if (IS_IOS && maskParams?.allowNegative !== false) { return undefined } - if (maskParams && mask?.instanceOf === 'createNumberMask') { - return maskParams.allowDecimal && maskParams.decimalLimit !== 0 - ? 'decimal' - : 'numeric' + return { + inputMode: + maskParams.allowDecimal && maskParams.decimalLimit !== 0 + ? 'decimal' + : 'numeric', } - return undefined } /** diff --git a/packages/dnb-eufemia/src/components/input-masked/TextMask.js b/packages/dnb-eufemia/src/components/input-masked/TextMask.js index dcc40ac72d4..327bb88f5ee 100644 --- a/packages/dnb-eufemia/src/components/input-masked/TextMask.js +++ b/packages/dnb-eufemia/src/components/input-masked/TextMask.js @@ -1,6 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' import createTextMaskInputElement from './text-mask/createTextMaskInputElement' +import InputModeNumber from './text-mask/InputModeNumber' import { isNil } from './text-mask/utilities' export default class TextMask extends React.PureComponent { @@ -47,17 +48,28 @@ export default class TextMask extends React.PureComponent { this.initTextMask() } + componentWillUnmount() { + this.inputMode?.remove() + } + initTextMask() { const { props, - props: { value }, + props: { value, inputMode }, } = this + const inputElement = this.inputRef.current this.textMaskInputElement = createTextMaskInputElement({ ...props, - inputElement: this.inputRef.current, + inputElement, }) this.textMaskInputElement.update(value) + + if (!inputMode && inputMode !== 'none') { + this.inputMode = new InputModeNumber() + } + + this.inputMode?.setElement(inputElement) } onChange = (event) => { diff --git a/packages/dnb-eufemia/src/components/input-masked/__tests__/InputMasked.test.tsx b/packages/dnb-eufemia/src/components/input-masked/__tests__/InputMasked.test.tsx index 023a4be22ee..0908f361e9d 100644 --- a/packages/dnb-eufemia/src/components/input-masked/__tests__/InputMasked.test.tsx +++ b/packages/dnb-eufemia/src/components/input-masked/__tests__/InputMasked.test.tsx @@ -5,7 +5,7 @@ import React from 'react' import { loadScss, wait } from '../../../core/jest/jestSetup' -import { render, fireEvent } from '@testing-library/react' +import { render, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import InputMasked, { InputMaskedProps } from '../InputMasked' import Provider from '../../../shared/Provider' @@ -340,80 +340,6 @@ describe('InputMasked component', () => { expect(document.querySelector('input').value).toBe('-0') }) - it('should set inputmode based on device and mask options', () => { - const onKeyDown = jest.fn() - const preventDefault = jest.fn() - - const { rerender } = render( - - ) - - expect(document.querySelector('input').getAttribute('inputmode')).toBe( - 'numeric' - ) - - Object.defineProperty(helpers, 'IS_ANDROID', { - value: true, - }) - - // Re-render - rerender() - - expect(document.querySelector('input').getAttribute('inputmode')).toBe( - null - ) - - // Re-render - rerender( - - ) - - expect(document.querySelector('input').getAttribute('inputmode')).toBe( - 'numeric' - ) - - // Re-render - rerender( - - ) - - expect(document.querySelector('input').getAttribute('inputmode')).toBe( - 'decimal' - ) - - Object.defineProperty(helpers, 'IS_ANDROID', { - value: false, - }) - - fireEvent.keyDown(document.querySelector('input'), { - key: ',', - keyCode: 229, // unidentified, while 188 would have worked fine - target: { - value: '1234.5', - }, - preventDefault, - }) - - expect(onKeyDown).toHaveBeenCalledTimes(1) - expect(preventDefault).toHaveBeenCalledTimes(0) - expect(document.querySelector('input').value).toBe('1234.5') - }) - it('should update value when initial value was an empty string', () => { const { rerender } = render() @@ -1896,6 +1822,283 @@ describe('InputMasked with custom mask', () => { }) }) +describe('inputmode', () => { + it('should be undefined with no props', () => { + render() + + expect( + document.querySelector('input').hasAttribute('inputmode') + ).toBeFalsy() + }) + + it('should be numeric with a number_mask', () => { + const onKeyDown = jest.fn() + + render() + + expect(document.querySelector('input')).toHaveAttribute( + 'inputmode', + 'numeric' + ) + }) + + it('should be decimal with a currency_mask', () => { + const onKeyDown = jest.fn() + + render() + + expect(document.querySelector('input')).toHaveAttribute( + 'inputmode', + 'decimal' + ) + }) + + it('should be numeric with as_percent', () => { + const onKeyDown = jest.fn() + + render() + + expect(document.querySelector('input')).toHaveAttribute( + 'inputmode', + 'numeric' + ) + }) + + it('should use numeric with no decimal and no negative/minus', () => { + const onKeyDown = jest.fn() + + render( + + ) + + expect(document.querySelector('input')).toHaveAttribute( + 'inputmode', + 'numeric' + ) + }) + + it('should use decimal with allowDecimal and no allowNegative', () => { + const onKeyDown = jest.fn() + + render( + + ) + + expect(document.querySelector('input')).toHaveAttribute( + 'inputmode', + 'decimal' + ) + }) + + it('should use decimal with allowDecimal and allowNegative', () => { + const onKeyDown = jest.fn() + + render( + + ) + + expect(document.querySelector('input')).toHaveAttribute( + 'inputmode', + 'decimal' + ) + }) + + it('should set custom inputMode', () => { + const onKeyDown = jest.fn() + + render( + + ) + + expect(document.querySelector('input')).toHaveAttribute( + 'inputmode', + 'tel' + ) + }) + + it('on iOS should remove "inputmode" when allowNegative is set', () => { + Object.defineProperty(helpers, 'IS_IOS', { + value: true, + }) + + const onKeyDown = jest.fn() + const preventDefault = jest.fn() + + render( + + ) + + expect(document.querySelector('input')).not.toHaveAttribute( + 'inputmode' + ) + expect(document.querySelector('input').getAttribute('type')).toBe( + 'text' + ) + + Object.defineProperty(helpers, 'IS_IOS', { + value: false, + }) + + fireEvent.keyDown(document.querySelector('input'), { + key: ',', + keyCode: 229, // unidentified, while 188 would have worked fine + target: { + value: '1234.5', + }, + preventDefault, + }) + + expect(onKeyDown).toHaveBeenCalledTimes(1) + expect(preventDefault).toHaveBeenCalledTimes(0) + expect(document.querySelector('input').value).toBe('1234.5') + }) + + it('should set type of number on focus when device is iOS (InputModeNumber)', async () => { + Object.defineProperty(helpers, 'IS_IOS', { + value: true, + }) + + render() + + const inputElement = document.querySelector('input') + + expect(inputElement.selectionStart).toBe(10) + expect(inputElement.selectionEnd).toBe(10) + + fireEvent.mouseEnter(inputElement) + + expect(inputElement).toHaveAttribute('type', 'number') + expect(inputElement).toHaveAttribute('placeholder', '1 234,5 kr') + expect(inputElement).toHaveAttribute('value', '1234,5') + expect(inputElement.value).toBe('') + expect(inputElement.selectionStart).toBe(null) + expect(inputElement.selectionEnd).toBe(null) + + await waitFor(() => { + expect(inputElement).toHaveAttribute('type', 'text') + expect(inputElement.value).toBe('1 234,5 kr') + }) + + expect(inputElement).toHaveAttribute('type', 'text') + expect(inputElement).toHaveAttribute('placeholder', '') + expect(inputElement.value).toBe('1 234,5 kr') + expect(inputElement.selectionStart).toBe(10) + expect(inputElement.selectionEnd).toBe(10) + + await userEvent.type(inputElement, '{Backspace>7}') + fireEvent.blur(inputElement) + + fireEvent.mouseEnter(inputElement) + + expect(inputElement).toHaveAttribute('type', 'number') + + await waitFor(() => { + expect(inputElement).toHaveAttribute('type', 'text') + expect(inputElement.value).toBe('') + }) + + Object.defineProperty(helpers, 'IS_IOS', { + value: false, + }) + }) + + it('should set type of number on label press when device is iOS (InputModeNumber)', async () => { + Object.defineProperty(helpers, 'IS_IOS', { + value: true, + }) + + render() + + const inputElement = document.querySelector('input') + const labelElement = document.querySelector('label') + + fireEvent.mouseDown(labelElement) + + expect(inputElement).toHaveAttribute('type', 'number') + + await waitFor(() => { + expect(inputElement).toHaveAttribute('type', 'text') + }) + + expect(inputElement).toHaveAttribute('type', 'text') + + await userEvent.type(inputElement, '{Backspace>7}') + fireEvent.blur(inputElement) + + fireEvent.mouseDown(labelElement) + + expect(inputElement).toHaveAttribute('type', 'number') + + await waitFor(() => { + expect(inputElement).toHaveAttribute('type', 'text') + }) + + Object.defineProperty(helpers, 'IS_IOS', { + value: false, + }) + }) + + it('should not set type of number on iOS, when inputMode is given (InputModeNumber)', async () => { + Object.defineProperty(helpers, 'IS_IOS', { + value: true, + }) + + render( + + ) + + const inputElement = document.querySelector('input') + + expect(inputElement.selectionStart).toBe(10) + expect(inputElement.selectionEnd).toBe(10) + + fireEvent.mouseEnter(inputElement) + + expect(inputElement).toHaveAttribute('inputmode', 'numeric') + expect(inputElement).toHaveAttribute('type', 'text') + + await waitFor(() => { + expect(inputElement).toHaveAttribute('type', 'text') + }) + + Object.defineProperty(helpers, 'IS_IOS', { + value: false, + }) + }) + + it('should not set type of number on focus when device is not iOS', () => { + render() + + const inputElement = document.querySelector('input') + + fireEvent.mouseEnter(inputElement) + + expect(inputElement).toHaveAttribute('type', 'text') + }) +}) + describe('InputMasked scss', () => { it('has to match style dependencies css', () => { const css = loadScss(require.resolve('../style/deps.scss')) diff --git a/packages/dnb-eufemia/src/components/input-masked/__tests__/__snapshots__/InputMasked.test.tsx.snap b/packages/dnb-eufemia/src/components/input-masked/__tests__/__snapshots__/InputMasked.test.tsx.snap index 29add7671cc..1f851f29736 100644 --- a/packages/dnb-eufemia/src/components/input-masked/__tests__/__snapshots__/InputMasked.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/input-masked/__tests__/__snapshots__/InputMasked.test.tsx.snap @@ -1280,6 +1280,9 @@ html[data-visual-test] .dnb-input__input { .dnb-input-masked--guide { font-family: var(--font-family-monospace); } +.dnb-input-masked input::placeholder { + color: inherit; +} .dnb-multi-input-mask__fieldset { margin: 0; diff --git a/packages/dnb-eufemia/src/components/input-masked/stories/InputMasked.stories.tsx b/packages/dnb-eufemia/src/components/input-masked/stories/InputMasked.stories.tsx index c2ee238ad0c..8321d165c13 100644 --- a/packages/dnb-eufemia/src/components/input-masked/stories/InputMasked.stories.tsx +++ b/packages/dnb-eufemia/src/components/input-masked/stories/InputMasked.stories.tsx @@ -22,6 +22,10 @@ export default { title: 'Eufemia/Components/InputMasked', } +export function TypeNumber() { + return +} + export function Sandbox() { const [locale, setLocale] = React.useState('nb-NO') return ( diff --git a/packages/dnb-eufemia/src/components/input-masked/style/dnb-input-masked.scss b/packages/dnb-eufemia/src/components/input-masked/style/dnb-input-masked.scss index 856908fafc2..983726fa9fc 100644 --- a/packages/dnb-eufemia/src/components/input-masked/style/dnb-input-masked.scss +++ b/packages/dnb-eufemia/src/components/input-masked/style/dnb-input-masked.scss @@ -9,6 +9,11 @@ &--guide { font-family: var(--font-family-monospace); } + + // Used on iOS when faking the inputMode to be type of number during focus – this fix avoids a flickering + input::placeholder { + color: inherit; + } } // MultiInputMask diff --git a/packages/dnb-eufemia/src/components/input-masked/text-mask/InputModeNumber.ts b/packages/dnb-eufemia/src/components/input-masked/text-mask/InputModeNumber.ts new file mode 100644 index 00000000000..38b98c404a5 --- /dev/null +++ b/packages/dnb-eufemia/src/components/input-masked/text-mask/InputModeNumber.ts @@ -0,0 +1,107 @@ +import { IS_IOS } from '../../../shared/helpers' + +/** + * This is a helper function (hack), + * that will evoke a good numeric keyboard (on iOS) that supports decimals and minus keys. + */ +export default class InputModeNumber { + inputElement: HTMLInputElement + labelElement: HTMLLabelElement + timeout: NodeJS.Timer + hasFocus: boolean + focusEventName: string + blurEventName: string + + setElement(element: HTMLInputElement) { + if (!IS_IOS) { + return // stop here + } + + /** + * Why use "mouseenter" and not "focus", "mousedown" or "touchstart"? + * - Because "touchstart" has unexpected behavior when holding the finger down before releasing. + * - And because "focus" and "mousedown" is too late, we then can't change the type anymore. + */ + this.focusEventName = 'mouseenter' + this.blurEventName = 'blur' + + if (!this.inputElement) { + this.inputElement = element + this.add() + this.handleLabel() + } + } + handleLabel() { + const id = this.inputElement?.id + if (!id) { + return + } + + this.labelElement = document.querySelector( + `[for="${id}"]` + ) as HTMLLabelElement + + if (this.labelElement) { + this.labelElement.addEventListener('mousedown', this.onFocus) + } + } + add() { + const fnId = '__getCorrectCaretPosition' + if (this.inputElement && !this.inputElement?.[fnId]) { + this.inputElement[fnId] = true + + this.inputElement.addEventListener(this.focusEventName, this.onFocus) + this.inputElement.addEventListener(this.blurEventName, this.onBlur) + } + } + removeEvent(element: HTMLInputElement | HTMLLabelElement) { + if (element) { + element.removeEventListener(this.focusEventName, this.onFocus) + element.removeEventListener(this.blurEventName, this.onBlur) + element.removeEventListener('mousedown', this.onFocus) + } + } + remove() { + clearTimeout(this.timeout) + + this.removeEvent(this.inputElement) + this.removeEvent(this.labelElement) + + delete this.inputElement + delete this.labelElement + } + onBlur = () => { + this.hasFocus = false + } + onFocus = () => { + if (this.hasFocus || !this.inputElement) { + return + } + + this.hasFocus = true + + const type = this.inputElement.type + + if (type === 'number') { + return // stop here + } + + const value = this.inputElement.value + const placeholder = this.inputElement.placeholder + + // To prevent flickering, show the placeholder, while the input value is "empty". + this.inputElement.placeholder = value + + // Changing the type, will remove the current input value to show as "empty". + this.inputElement.type = 'number' + + // Reset the input again + clearTimeout(this.timeout) + this.timeout = setTimeout(() => { + this.inputElement.type = type + this.inputElement.value = value // set the input value + this.inputElement.placeholder = placeholder + this.inputElement['runCorrectCaretPosition']?.() + }, 5) + } +} diff --git a/packages/dnb-eufemia/src/components/input-masked/text-mask/createTextMaskInputElement.js b/packages/dnb-eufemia/src/components/input-masked/text-mask/createTextMaskInputElement.js index 51c2d7e004d..e58ac45a73a 100644 --- a/packages/dnb-eufemia/src/components/input-masked/text-mask/createTextMaskInputElement.js +++ b/packages/dnb-eufemia/src/components/input-masked/text-mask/createTextMaskInputElement.js @@ -236,7 +236,7 @@ export function safeSetSelection(element, selectionPosition) { 0 ) } else { - element.setSelectionRange(selectionPosition, selectionPosition) + element?.setSelectionRange(selectionPosition, selectionPosition) } } }