feat(form-core): support format on paste
This commit is contained in:
parent
5da55af6cd
commit
c6fbe9208a
6 changed files with 150 additions and 11 deletions
5
.changeset/wicked-games-dress.md
Normal file
5
.changeset/wicked-games-dress.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/form-core': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
support paste functionality in FormatMixin
|
||||||
|
|
@ -129,7 +129,7 @@ const FormatMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret
|
// We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret
|
||||||
/** @type {string} */
|
/** @param {string} value */
|
||||||
set value(value) {
|
set value(value) {
|
||||||
// if not yet connected to dom can't change the value
|
// if not yet connected to dom can't change the value
|
||||||
if (this._inputNode) {
|
if (this._inputNode) {
|
||||||
|
|
@ -350,7 +350,16 @@ const FormatMixinImplementation = superclass =>
|
||||||
if (!this.__isHandlingComposition) {
|
if (!this.__isHandlingComposition) {
|
||||||
this.value = this.preprocessor(this.value);
|
this.value = this.preprocessor(this.value);
|
||||||
}
|
}
|
||||||
|
const prevFormatted = this.formattedValue;
|
||||||
this.modelValue = this._callParser(this.value);
|
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;
|
return !this._isHandlingUserInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This can be called whenever the view value should be updated. Dependent on component type
|
/**
|
||||||
// ("input" for <input> or "change" for <select>(mainly for IE)) a different event should be
|
* This can be called whenever the view value should be updated. Dependent on component type
|
||||||
// used as source for the "user-input-changed" event (which can be seen as an abstraction
|
* ("input" for <input> or "change" for <select>(mainly for IE)) a different event should be
|
||||||
// layer on top of other events (input, change, whatever))
|
* used as source for the "user-input-changed" event (which can be seen as an abstraction
|
||||||
/** @protected */
|
* layer on top of other events (input, change, whatever))
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
_proxyInputEvent() {
|
_proxyInputEvent() {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('user-input-changed', {
|
new CustomEvent('user-input-changed', {
|
||||||
|
|
@ -419,8 +430,36 @@ const FormatMixinImplementation = superclass =>
|
||||||
super();
|
super();
|
||||||
this.formatOn = 'change';
|
this.formatOn = 'change';
|
||||||
this.formatOptions = /** @type {FormatOptions} */ ({});
|
this.formatOptions = /** @type {FormatOptions} */ ({});
|
||||||
|
/**
|
||||||
|
* Whether the user is pasting content. Allows Subclassers to do this in their subclass:
|
||||||
|
* @example
|
||||||
|
* ```js
|
||||||
|
* _reflectBackFormattedValueToUser() {
|
||||||
|
* return super._reflectBackFormattedValueToUser() || this._isPasting;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
this._isPasting = false;
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.__prevViewValue = '';
|
||||||
this.__onCompositionEvent = this.__onCompositionEvent.bind(this);
|
this.__onCompositionEvent = this.__onCompositionEvent.bind(this);
|
||||||
|
// This computes formattedValue
|
||||||
|
this.addEventListener('user-input-changed', this._onUserInputChanged);
|
||||||
|
// This sets the formatted viewValue after paste
|
||||||
|
this.addEventListener('paste', this.__onPaste);
|
||||||
|
}
|
||||||
|
|
||||||
|
__onPaste() {
|
||||||
|
this._isPasting = true;
|
||||||
|
this.formatOptions.mode = 'pasted';
|
||||||
|
setTimeout(() => {
|
||||||
|
this._isPasting = false;
|
||||||
|
this.formatOptions.mode = 'auto';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -432,7 +471,7 @@ const FormatMixinImplementation = superclass =>
|
||||||
// is guaranteed to be calculated
|
// is guaranteed to be calculated
|
||||||
setTimeout(this._reflectBackFormattedValueToUser);
|
setTimeout(this._reflectBackFormattedValueToUser);
|
||||||
};
|
};
|
||||||
this.addEventListener('user-input-changed', this._onUserInputChanged);
|
|
||||||
// Connect the value found in <input> to the formatting/parsing/serializing loop as a
|
// Connect the value found in <input> to the formatting/parsing/serializing loop as a
|
||||||
// fallback mechanism. Assume the user uses the value property of the
|
// fallback mechanism. Assume the user uses the value property of the
|
||||||
// `LionField`(recommended api) as the api (this is a downwards sync).
|
// `LionField`(recommended api) as the api (this is a downwards sync).
|
||||||
|
|
@ -453,7 +492,6 @@ const FormatMixinImplementation = superclass =>
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.removeEventListener('user-input-changed', this._onUserInputChanged);
|
|
||||||
if (this._inputNode) {
|
if (this._inputNode) {
|
||||||
this._inputNode.removeEventListener('input', this._proxyInputEvent);
|
this._inputNode.removeEventListener('input', this._proxyInputEvent);
|
||||||
this._inputNode.removeEventListener(
|
this._inputNode.removeEventListener(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { dedupeMixin } from '@lion/core';
|
import { dedupeMixin } from '@lion/core';
|
||||||
import { FormControlMixin } from './FormControlMixin.js';
|
import { FormControlMixin } from './FormControlMixin.js';
|
||||||
import { FocusMixin } from './FocusMixin.js';
|
import { FocusMixin } from './FocusMixin.js';
|
||||||
|
import { FormatMixin } from './FormatMixin.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {import('../types/NativeTextFieldMixinTypes').NativeTextFieldMixin} NativeTextFieldMixin
|
* @typedef {import('../types/NativeTextFieldMixinTypes').NativeTextFieldMixin} NativeTextFieldMixin
|
||||||
|
|
@ -8,7 +9,7 @@ import { FocusMixin } from './FocusMixin.js';
|
||||||
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass} superclass
|
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass} superclass
|
||||||
*/
|
*/
|
||||||
const NativeTextFieldMixinImplementation = superclass =>
|
const NativeTextFieldMixinImplementation = superclass =>
|
||||||
class NativeTextFieldMixin extends FocusMixin(FormControlMixin(superclass)) {
|
class NativeTextFieldMixin extends FormatMixin(FocusMixin(FormControlMixin(superclass))) {
|
||||||
/**
|
/**
|
||||||
* @protected
|
* @protected
|
||||||
* @type {HTMLInputElement | HTMLTextAreaElement}
|
* @type {HTMLInputElement | HTMLTextAreaElement}
|
||||||
|
|
@ -95,6 +96,20 @@ const NativeTextFieldMixinImplementation = superclass =>
|
||||||
this._inputNode.value = newValue;
|
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);
|
export const NativeTextFieldMixin = dedupeMixin(NativeTextFieldMixinImplementation);
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,73 @@ export function runFormatMixinSuite(customConfig) {
|
||||||
expect(spyArg.locale).to.equal('en-GB');
|
expect(spyArg.locale).to.equal('en-GB');
|
||||||
expect(spyArg.decimalSeparator).to.equal('-');
|
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}><input slot="input"></${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}><input slot="input"></${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}><input slot="input"></${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}><input slot="input"></${reflectingTag}>
|
||||||
|
`));
|
||||||
|
// @ts-ignore [allow-protected] in test
|
||||||
|
const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
|
||||||
|
paste(el);
|
||||||
|
expect(reflectBackSpy).to.have.been.called;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Parser', () => {
|
describe('Parser', () => {
|
||||||
|
|
|
||||||
13
packages/form-core/types/FormatMixinTypes.d.ts
vendored
13
packages/form-core/types/FormatMixinTypes.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
import { Constructor } from '@open-wc/dedupe-mixin';
|
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 { FormatNumberOptions } from '@lion/localize/types/LocalizeMixinTypes';
|
||||||
import { ValidateHost } from './validate/ValidateMixinTypes';
|
import { ValidateHost } from './validate/ValidateMixinTypes';
|
||||||
import { FormControlHost } from './FormControlMixinTypes';
|
import { FormControlHost } from './FormControlMixinTypes';
|
||||||
|
|
@ -18,6 +18,16 @@ export declare class FormatHost {
|
||||||
set value(value: string);
|
set value(value: string);
|
||||||
|
|
||||||
protected _isHandlingUserInput: boolean;
|
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 _calculateValues(opts: { source: 'model' | 'serialized' | 'formatted' | null }): void;
|
||||||
protected _onModelValueChanged(arg: { modelValue: unknown }): void;
|
protected _onModelValueChanged(arg: { modelValue: unknown }): void;
|
||||||
protected _dispatchModelValueChangedEvent(): void;
|
protected _dispatchModelValueChangedEvent(): void;
|
||||||
|
|
@ -31,6 +41,7 @@ export declare class FormatHost {
|
||||||
protected _callFormatter(): string;
|
protected _callFormatter(): string;
|
||||||
|
|
||||||
private __preventRecursiveTrigger: boolean;
|
private __preventRecursiveTrigger: boolean;
|
||||||
|
private __prevViewValue: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare function FormatImplementation<T extends Constructor<LitElement>>(
|
export declare function FormatImplementation<T extends Constructor<LitElement>>(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Constructor } from '@open-wc/dedupe-mixin';
|
||||||
import { LitElement } from '@lion/core';
|
import { LitElement } from '@lion/core';
|
||||||
import { FocusHost } from '@lion/form-core/types/FocusMixinTypes';
|
import { FocusHost } from '@lion/form-core/types/FocusMixinTypes';
|
||||||
import { FormControlHost } from '@lion/form-core/types/FormControlMixinTypes';
|
import { FormControlHost } from '@lion/form-core/types/FormControlMixinTypes';
|
||||||
|
import { FormatHost } from '@lion/form-core/types/FormatMixinTypes';
|
||||||
|
|
||||||
export declare class NativeTextFieldHost {
|
export declare class NativeTextFieldHost {
|
||||||
get selectionStart(): number;
|
get selectionStart(): number;
|
||||||
|
|
@ -15,6 +16,8 @@ export declare function NativeTextFieldImplementation<T extends Constructor<LitE
|
||||||
): T &
|
): T &
|
||||||
Constructor<NativeTextFieldHost> &
|
Constructor<NativeTextFieldHost> &
|
||||||
Pick<typeof NativeTextFieldHost, keyof typeof NativeTextFieldHost> &
|
Pick<typeof NativeTextFieldHost, keyof typeof NativeTextFieldHost> &
|
||||||
|
Constructor<FormatHost> &
|
||||||
|
Pick<typeof FormatHost, keyof typeof FormatHost> &
|
||||||
Constructor<FocusHost> &
|
Constructor<FocusHost> &
|
||||||
Pick<typeof FocusHost, keyof typeof FocusHost> &
|
Pick<typeof FocusHost, keyof typeof FocusHost> &
|
||||||
Constructor<FormControlHost> &
|
Constructor<FormControlHost> &
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue