Skip to content

Poc/alternative amount input #2442

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/bright-snakes-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lion/ui': minor
---

[form-core] add "user-edit" mode to formatOptions while editing existing value of a form control
36 changes: 21 additions & 15 deletions packages/ui/components/form-core/src/FormatMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const FormatMixinImplementation = superclass =>
// if not yet connected to dom can't change the value
if (this._inputNode) {
this._inputNode.value = value;
this._viewInputNode.value = value;
/** @type {string | undefined} */
this.__value = undefined;
} else {
Expand Down Expand Up @@ -249,7 +250,7 @@ const FormatMixinImplementation = superclass =>
// Apparently, the parser was not able to produce a satisfactory output for the desired
// modelValue type, based on the current viewValue. Unparseable allows to restore all
// states (for instance from a lost user session), since it saves the current viewValue.
const result = this.parser(value, this.formatOptions);
const result = this.parser(value, { ...this.formatOptions, mode: this.#getFormatMode() });
return result !== undefined ? result : new Unparseable(value);
}

Expand All @@ -269,13 +270,8 @@ const FormatMixinImplementation = superclass =>
// imperatively, we DO want to format a value (it is the only way to get meaningful
// input into `._inputNode` with modelValue as input)

if (
this._isHandlingUserInput &&
this.hasFeedbackFor?.length &&
this.hasFeedbackFor.includes('error') &&
this._inputNode
) {
return this._inputNode ? this.value : undefined;
if (this._isHandlingUserInput && this.hasFeedbackFor?.includes('error') && this._inputNode) {
return this.value;
}

if (this.modelValue instanceof Unparseable) {
Expand All @@ -285,7 +281,10 @@ const FormatMixinImplementation = superclass =>
return this.modelValue.viewValue;
}

return this.formatter(this.modelValue, this.formatOptions);
return this.formatter(this.modelValue, {
...this.formatOptions,
mode: this.#getFormatMode(),
});
}

/**
Expand Down Expand Up @@ -348,7 +347,6 @@ const FormatMixinImplementation = superclass =>
* @private
*/
__handlePreprocessor() {
const unprocessedValue = this.value;
let currentCaretIndex = this.value.length;
// Be gentle with Safari
if (
Expand All @@ -364,7 +362,6 @@ const FormatMixinImplementation = superclass =>
prevViewValue: this.__prevViewValue,
});

this.__prevViewValue = unprocessedValue;
if (preprocessedValue === undefined) {
// Make sure we do no set back original value, so we preserve
// caret index (== selectionStart/selectionEnd)
Expand Down Expand Up @@ -459,7 +456,7 @@ const FormatMixinImplementation = superclass =>
/**
* Configuration object that will be available inside the formatter function
*/
this.formatOptions = /** @type {FormatOptions} */ ({});
this.formatOptions = /** @type {FormatOptions} */ ({ mode: 'auto' });

/**
* The view value is the result of the formatter function (when available).
Expand Down Expand Up @@ -543,10 +540,8 @@ const FormatMixinImplementation = superclass =>
*/
__onPaste() {
this._isPasting = true;
this.formatOptions.mode = 'pasted';
setTimeout(() => {
queueMicrotask(() => {
this._isPasting = false;
this.formatOptions.mode = 'auto';
});
}

Expand Down Expand Up @@ -588,6 +583,17 @@ const FormatMixinImplementation = superclass =>
this._inputNode.removeEventListener('compositionend', this.__onCompositionEvent);
}
}

#getFormatMode() {
if (this._isPasting) {
return 'pasted';
}
const isUserEditing = this._isHandlingUserInput && this.__prevViewValue;
if (isUserEditing) {
return 'user-edit';
}
return 'auto';
}
};

export const FormatMixin = dedupeMixin(FormatMixinImplementation);
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import sinon from 'sinon';
import { Unparseable, Validator, FormatMixin } from '@lion/ui/form-core.js';
import { getFormControlMembers, mimicUserInput } from '@lion/ui/form-core-test-helpers.js';

const isLionInputStepper = (/** @type {FormatClass} */ el) => 'valueTextMapping' in el;

/**
* @typedef {import('../types/FormControlMixinTypes.js').FormControlHost} FormControlHost
* @typedef {ArrayConstructor | ObjectConstructor | NumberConstructor | BooleanConstructor | StringConstructor | DateConstructor | 'iban' | 'email'} modelValueType
Expand Down Expand Up @@ -480,7 +482,7 @@ export function runFormatMixinSuite(customConfig) {
/**
* @param {FormatClass} el
*/
function paste(el, val = 'lorem') {
function mimicPaste(el, val = 'lorem') {
const { _inputNode } = getFormControlMembers(el);
_inputNode.value = val;
_inputNode.dispatchEvent(new ClipboardEvent('paste', { bubbles: true }));
Expand All @@ -494,12 +496,17 @@ export function runFormatMixinSuite(customConfig) {
`)
);
const formatterSpy = sinon.spy(el, 'formatter');
paste(el);
mimicPaste(el);
expect(formatterSpy).to.be.called;
expect(/** @type {{mode: string}} */ (formatterSpy.args[0][1]).mode).to.equal('pasted');
expect(/** @type {{mode: string}} */ (formatterSpy.lastCall.args[1]).mode).to.equal(
'pasted',
);
// give microtask of _isPasting chance to reset
await aTimeout(0);
mimicUserInput(el, '');
expect(/** @type {{mode: string}} */ (formatterSpy.args[0][1]).mode).to.equal('auto');
el.modelValue = 'foo';
expect(/** @type {{mode: string}} */ (formatterSpy.lastCall.args[1]).mode).to.equal(
'auto',
);
});

it('sets protected value "_isPasting" for Subclassers', async () => {
Expand All @@ -509,7 +516,7 @@ export function runFormatMixinSuite(customConfig) {
`)
);
const formatterSpy = sinon.spy(el, 'formatter');
paste(el);
mimicPaste(el);
expect(formatterSpy).to.have.been.called;
// @ts-ignore [allow-protected] in test
expect(el._isPasting).to.be.true;
Expand All @@ -526,7 +533,7 @@ export function runFormatMixinSuite(customConfig) {
);
// @ts-ignore [allow-protected] in test
const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
paste(el);
mimicPaste(el);
expect(reflectBackSpy).to.have.been.called;
});

Expand All @@ -538,10 +545,33 @@ export function runFormatMixinSuite(customConfig) {
);
// @ts-ignore [allow-protected] in test
const reflectBackSpy = sinon.spy(el, '_reflectBackOn');
paste(el);
mimicPaste(el);
expect(reflectBackSpy).to.have.been.called;
});
});

describe('On user input', () => {
it('adjusts formatOptions.mode to "user-edit" for parser when user changes value', async () => {
const el = /** @type {FormatClass} */ (
await fixture(html`<${tag}><input slot="input"></${tag}>`)
);

const parserSpy = sinon.spy(el, 'parser');

// Here we get auto as we start from '' (so there was no value to edit)
mimicUserInput(el, 'some val');
expect(/** @type {{mode: string}} */ (parserSpy.lastCall.args[1]).mode).to.equal(
'auto',
);
await el.updateComplete;

mimicUserInput(el, 'some other val');
expect(/** @type {{mode: string}} */ (parserSpy.lastCall.args[1]).mode).to.equal(
'user-edit',
);
await el.updateComplete;
});
});
});

describe('Parser', () => {
Expand Down Expand Up @@ -597,6 +627,43 @@ export function runFormatMixinSuite(customConfig) {
expect(_inputNode.value).to.equal(val);
});

it('does only calculate derived values as consequence of user input when preprocessed value is different from previous view value', async () => {
const val = generateValueBasedOnType({ viewValue: true }) || 'init-value';
if (typeof val !== 'string') return;

const preprocessorSpy = sinon.spy(v => v.replace(/\$$/g, ''));
const el = /** @type {FormatClass} */ (
await fixture(html`
<${tag} .preprocessor=${preprocessorSpy}>
<input slot="input" .value="${val}">
</${tag}>
`)
);

// TODO: find out why we need to skip this for lion-input-stepper
if (isLionInputStepper(el)) return;

/**
* The _calculateValues method is called inside _onUserInputChanged w/o providing args
* @param {sinon.SinonSpyCall} call
* @returns {boolean}
*/
const isCalculateCallAfterUserInput = call => call.args[0]?.length === 0;

const didRecalculateAfterUserInput = (/** @type {sinon.SinonSpy<any[], any>} */ spy) =>
spy.callCount > 1 && !spy.getCalls().find(isCalculateCallAfterUserInput);

// @ts-expect-error [allow-protected] in test
const calcValuesSpy = sinon.spy(el, '_calculateValues');
// this value gets preprocessed to 'val'
mimicUserInput(el, `${val}$`);
expect(didRecalculateAfterUserInput(calcValuesSpy)).to.be.false;

// this value gets preprocessed to 'value' (and thus differs from previous)
mimicUserInput(el, `${val}ue$`);
expect(didRecalculateAfterUserInput(calcValuesSpy)).to.be.true;
});

it('does not preprocess during composition', async () => {
const el = /** @type {FormatClass} */ (
await fixture(html`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { LitElement } from 'lit';
import { ValidateHost } from './validate/ValidateMixinTypes.js';
import { FormControlHost } from './FormControlMixinTypes.js';

export type FormatOptions = { mode: 'pasted' | 'auto' } & object;
export type FormatOptions = { mode: 'pasted' | 'auto' | 'user-edit'} & object;
export declare class FormatHost {
/**
* Converts viewValue to modelValue
Expand Down
68 changes: 67 additions & 1 deletion packages/ui/components/input-amount/src/LionInputAmount.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { css } from 'lit';
import { css, html } from 'lit';
import { LionInput } from '@lion/ui/input.js';
import { getCurrencyName, LocalizeMixin } from '@lion/ui/localize-no-side-effects.js';
import { IsNumber } from '@lion/ui/form-core.js';
Expand Down Expand Up @@ -51,6 +51,7 @@ export class LionInputAmount extends LocalizeMixin(LionInput) {
el.textContent = this.__currencyLabel;
return el;
},
'view-input': () => html`<input />`,
};
}

Expand Down Expand Up @@ -79,17 +80,82 @@ export class LionInputAmount extends LocalizeMixin(LionInput) {
this._currencyDisplayNodeSlot = 'after';
}

get _viewInputNode() {
return Array.from(this.children).find(child => child.slot === 'view-input');
}

/**
* @enhance FormControlMixin
* @return {import('lit').TemplateResult}
* @protected
*/
// eslint-disable-next-line class-methods-use-this
_inputGroupInputTemplate() {
return html`
<div class="input-group__input">
<slot name="input"></slot>
<slot name="view-input"></slot>
</div>
`;
}

connectedCallback() {
// eslint-disable-next-line wc/guard-super-call
super.connectedCallback();
this.type = 'text';
this._inputNode.setAttribute('inputmode', 'decimal');

this._viewInputNode.addEventListener('focus', this.#swapInputsOnFocus);
this._viewInputNode?.setAttribute('aria-hidden', 'true');
this._inputNode.addEventListener('blur', this.#showViewInput);
this.#showViewInput();
this._viewInputNode?.classList.add('form-control');

this.updateComplete.then(() => {
this._inputNode.type = 'number';
this._inputNode.step = 0.01;
});

if (this.currency) {
this.__setCurrencyDisplayLabel();
}
}

// TODO: just swap input type and value of _inputNode with 1 input
#swapInputsOnFocus = ev => {
ev.preventDefault();
ev.stopPropagation();
this._viewInputNode.style.display = 'none';
this._inputNode.style.display = '';
this._inputNode.focus();
};

#showViewInput = () => {
this._viewInputNode.style.display = '';
// todo: sr only for a11y
this._inputNode.style.display = 'none';
};

/**
* The view value. Will be delegated to `._inputNode.value`
*/
get value() {
return (this._inputNode && this._inputNode.value) || this.__value || '';
}

/** @param {string} value */
set value(value) {
// if not yet connected to dom can't change the value
if (this._inputNode) {
this._inputNode.value = parseAmount(value, this.formatOptions) || '';
this._viewInputNode.value = value;
/** @type {string | undefined} */
this.__value = undefined;
} else {
this.__value = value;
}
}

/** @param {import('lit').PropertyValues } changedProperties */
updated(changedProperties) {
super.updated(changedProperties);
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/components/input-amount/src/parsers.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { parseNumber, getFractionDigits } from '@lion/ui/localize-no-side-effects.js';

/**
* @typedef {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} FormatOptions
* @typedef {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} FormatNumberOptions
*/

/**
Expand All @@ -28,7 +28,7 @@ function round(value, decimals) {
* parseAmount('1,234.56', {currency: 'JOD'}); => 1234.560
*
* @param {string} value Number to be parsed
* @param {FormatOptions} [givenOptions] Locale Options
* @param {FormatNumberOptions} [givenOptions] Locale Options
*/
export function parseAmount(value, givenOptions) {
const unmatchedInput = value.match(/[^0-9,.\- ]/g);
Expand All @@ -44,7 +44,7 @@ export function parseAmount(value, givenOptions) {
return undefined;
}

/** @type {FormatOptions} */
/** @type {FormatNumberOptions} */
const options = {
...givenOptions,
};
Expand Down
Loading
Loading