diff --git a/.changeset/wicked-games-dress.md b/.changeset/wicked-games-dress.md new file mode 100644 index 000000000..0cc28273f --- /dev/null +++ b/.changeset/wicked-games-dress.md @@ -0,0 +1,5 @@ +--- +'@lion/form-core': minor +--- + +support paste functionality in FormatMixin diff --git a/packages/form-core/src/FormatMixin.js b/packages/form-core/src/FormatMixin.js index cf2b611ab..4907de5b0 100644 --- a/packages/form-core/src/FormatMixin.js +++ b/packages/form-core/src/FormatMixin.js @@ -129,7 +129,7 @@ const FormatMixinImplementation = superclass => } // We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret - /** @type {string} */ + /** @param {string} value */ set value(value) { // if not yet connected to dom can't change the value if (this._inputNode) { @@ -350,7 +350,16 @@ const FormatMixinImplementation = superclass => if (!this.__isHandlingComposition) { this.value = this.preprocessor(this.value); } + const prevFormatted = this.formattedValue; this.modelValue = this._callParser(this.value); + + // Sometimes, the formattedValue didn't change, but the viewValue did... + // We need this check to support pasting values that need to be formatted right on paste + if (prevFormatted === this.formattedValue && this.__prevViewValue !== this.value) { + this._calculateValues(); + } + /** @type {string} */ + this.__prevViewValue = this.value; } /** @@ -379,11 +388,13 @@ const FormatMixinImplementation = superclass => return !this._isHandlingUserInput; } - // This can be called whenever the view value should be updated. Dependent on component type - // ("input" for or "change" for or "change" for to the formatting/parsing/serializing loop as a // fallback mechanism. Assume the user uses the value property of the // `LionField`(recommended api) as the api (this is a downwards sync). @@ -453,7 +492,6 @@ const FormatMixinImplementation = superclass => disconnectedCallback() { super.disconnectedCallback(); - this.removeEventListener('user-input-changed', this._onUserInputChanged); if (this._inputNode) { this._inputNode.removeEventListener('input', this._proxyInputEvent); this._inputNode.removeEventListener( diff --git a/packages/form-core/src/NativeTextFieldMixin.js b/packages/form-core/src/NativeTextFieldMixin.js index 4c32bc071..47bd38b48 100644 --- a/packages/form-core/src/NativeTextFieldMixin.js +++ b/packages/form-core/src/NativeTextFieldMixin.js @@ -1,6 +1,7 @@ import { dedupeMixin } from '@lion/core'; import { FormControlMixin } from './FormControlMixin.js'; import { FocusMixin } from './FocusMixin.js'; +import { FormatMixin } from './FormatMixin.js'; /** * @typedef {import('../types/NativeTextFieldMixinTypes').NativeTextFieldMixin} NativeTextFieldMixin @@ -8,7 +9,7 @@ import { FocusMixin } from './FocusMixin.js'; * @param {import('@open-wc/dedupe-mixin').Constructor} superclass} superclass */ const NativeTextFieldMixinImplementation = superclass => - class NativeTextFieldMixin extends FocusMixin(FormControlMixin(superclass)) { + class NativeTextFieldMixin extends FormatMixin(FocusMixin(FormControlMixin(superclass))) { /** * @protected * @type {HTMLInputElement | HTMLTextAreaElement} @@ -95,6 +96,20 @@ const NativeTextFieldMixinImplementation = superclass => this._inputNode.value = newValue; } } + + /** + * @override FormatMixin + */ + _reflectBackFormattedValueToUser() { + super._reflectBackFormattedValueToUser(); + if (this._reflectBackOn() && this.focused) { + try { + // try/catch, because Safari is a bit sensitive here + this._inputNode.selectionStart = this._inputNode.value.length; + // eslint-disable-next-line no-empty + } catch (_) {} + } + } }; export const NativeTextFieldMixin = dedupeMixin(NativeTextFieldMixinImplementation); diff --git a/packages/form-core/test-suites/FormatMixin.suite.js b/packages/form-core/test-suites/FormatMixin.suite.js index f222ac7e3..6f88b333c 100644 --- a/packages/form-core/test-suites/FormatMixin.suite.js +++ b/packages/form-core/test-suites/FormatMixin.suite.js @@ -462,6 +462,73 @@ export function runFormatMixinSuite(customConfig) { expect(spyArg.locale).to.equal('en-GB'); expect(spyArg.decimalSeparator).to.equal('-'); }); + + describe('On paste', () => { + class ReflectOnPaste extends FormatClass { + _reflectBackOn() { + return super._reflectBackOn() || this._isPasting; + } + } + const reflectingTagString = defineCE(ReflectOnPaste); + const reflectingTag = unsafeStatic(reflectingTagString); + + /** + * @param {FormatClass} el + */ + function paste(el, val = 'lorem') { + const { _inputNode } = getFormControlMembers(el); + _inputNode.value = val; + _inputNode.dispatchEvent(new ClipboardEvent('paste', { bubbles: true })); + _inputNode.dispatchEvent(new Event('input', { bubbles: true })); + } + + it('sets formatOptions.mode to "pasted" (and restores to "auto")', async () => { + const el = /** @type {FormatClass} */ (await fixture(html` + <${reflectingTag}> + `)); + const formatterSpy = sinon.spy(el, 'formatter'); + paste(el); + expect(formatterSpy).to.be.called; + expect(/** @type {{mode: string}} */ (formatterSpy.args[0][1]).mode).to.equal('pasted'); + await aTimeout(0); + mimicUserInput(el, ''); + expect(/** @type {{mode: string}} */ (formatterSpy.args[0][1]).mode).to.equal('auto'); + }); + + it('sets protected value "_isPasting" for Subclassers', async () => { + const el = /** @type {FormatClass} */ (await fixture(html` + <${reflectingTag}> + `)); + const formatterSpy = sinon.spy(el, 'formatter'); + paste(el); + expect(formatterSpy).to.have.been.called; + // @ts-ignore [allow-protected] in test + expect(el._isPasting).to.be.true; + await aTimeout(0); + // @ts-ignore [allow-protected] in test + expect(el._isPasting).to.be.false; + }); + + it('calls formatter and "_reflectBackOn()"', async () => { + const el = /** @type {FormatClass} */ (await fixture(html` + <${tag}> + `)); + // @ts-ignore [allow-protected] in test + const reflectBackSpy = sinon.spy(el, '_reflectBackOn'); + paste(el); + expect(reflectBackSpy).to.have.been.called; + }); + + it(`updates viewValue when "_reflectBackOn()" configured to reflect`, async () => { + const el = /** @type {FormatClass} */ (await fixture(html` + <${reflectingTag}> + `)); + // @ts-ignore [allow-protected] in test + const reflectBackSpy = sinon.spy(el, '_reflectBackOn'); + paste(el); + expect(reflectBackSpy).to.have.been.called; + }); + }); }); describe('Parser', () => { diff --git a/packages/form-core/types/FormatMixinTypes.d.ts b/packages/form-core/types/FormatMixinTypes.d.ts index 1c19c336c..47500ff12 100644 --- a/packages/form-core/types/FormatMixinTypes.d.ts +++ b/packages/form-core/types/FormatMixinTypes.d.ts @@ -1,5 +1,5 @@ import { Constructor } from '@open-wc/dedupe-mixin'; -import { LitElement } from '@lion/core'; +import { BooleanAttributePart, LitElement } from '@lion/core'; import { FormatNumberOptions } from '@lion/localize/types/LocalizeMixinTypes'; import { ValidateHost } from './validate/ValidateMixinTypes'; import { FormControlHost } from './FormControlMixinTypes'; @@ -18,6 +18,16 @@ export declare class FormatHost { set value(value: string); protected _isHandlingUserInput: boolean; + /** + * Whether the user is pasting content. Allows Subclassers to do this in their subclass: + * @example + * ```js + * _reflectBackFormattedValueToUser() { + * return super._reflectBackFormattedValueToUser() || this._isPasting; + * } + * ``` + */ + protected _isPasting: boolean; protected _calculateValues(opts: { source: 'model' | 'serialized' | 'formatted' | null }): void; protected _onModelValueChanged(arg: { modelValue: unknown }): void; protected _dispatchModelValueChangedEvent(): void; @@ -31,6 +41,7 @@ export declare class FormatHost { protected _callFormatter(): string; private __preventRecursiveTrigger: boolean; + private __prevViewValue: string; } export declare function FormatImplementation>( diff --git a/packages/form-core/types/NativeTextFieldMixinTypes.d.ts b/packages/form-core/types/NativeTextFieldMixinTypes.d.ts index fce49290e..5f0df11de 100644 --- a/packages/form-core/types/NativeTextFieldMixinTypes.d.ts +++ b/packages/form-core/types/NativeTextFieldMixinTypes.d.ts @@ -2,6 +2,7 @@ import { Constructor } from '@open-wc/dedupe-mixin'; import { LitElement } from '@lion/core'; import { FocusHost } from '@lion/form-core/types/FocusMixinTypes'; import { FormControlHost } from '@lion/form-core/types/FormControlMixinTypes'; +import { FormatHost } from '@lion/form-core/types/FormatMixinTypes'; export declare class NativeTextFieldHost { get selectionStart(): number; @@ -15,6 +16,8 @@ export declare function NativeTextFieldImplementation & Pick & + Constructor & + Pick & Constructor & Pick & Constructor &