Api: Fix number could not handle comma as decimal separator if different from browser's region.

This commit is contained in:
nathan 2023-03-22 10:59:05 -06:00
parent 250beda64e
commit e034557f60
5 changed files with 366 additions and 39 deletions

View File

@ -0,0 +1,108 @@
/**
* EGroupware eTemplate2 - Duration date widget (WebComponent)
*
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package etemplate
* @subpackage api
* @link https://www.egroupware.org
* @author Nathan Gray
*/
import {css, html, LitElement} from "@lion/core";
import {ButtonMixin} from "./ButtonMixin";
/**
* Up / Down spinner buttons are used to adjust a value by a set amount
*
* @event et2-scroll Emitted when one of the buttons is clicked. Check event.detail for direction. 1 for up, -1 for down.
*
* example:
* Add the scroll into an input, then catch the et2-scroll event to adjust the value:
* <et2-button-scroll slot="suffix" @et2-scroll=${this.handleScroll}></et2-button-scroll>
*
* handleScroll(e) {
* this.value = "" + (this.valueAsNumber + e.detail * (parseFloat(this.step) || 1));
* }
*/
export class Et2ButtonScroll extends ButtonMixin(LitElement)
{
static get styles()
{
return [
...(super.styles ? (Array.isArray(super.styles) ? super.styles : [super.styles]) : []),
css`
/* Scroll buttons */
.et2-button-scroll {
display: flex;
flex-direction: column;
width: calc(var(--sl-input-height-medium) / 2);
}
et2-button-icon {
font-size: 85%;
height: calc(var(--sl-input-height-medium) / 2);
/* Override spacing in sl-icon-button */
--sl-spacing-x-small: 3px;
}
`,
];
}
constructor()
{
super();
this.handleClick = this.handleClick.bind(this);
}
/**
* Catch clicks on buttons and dispatch an et2-scroll event with the direction included
*
* @param e
* @private
*/
private handleClick(e)
{
const direction = parseInt(e.target.dataset.direction || "1") || 0;
e.stopPropagation();
this.dispatchEvent(new CustomEvent("et2-scroll", {bubbles: true, detail: direction}));
}
render()
{
// No spinner buttons on mobile
if(typeof egwIsMobile == "function" && egwIsMobile())
{
return '';
}
return html`
<div class="et2-button-scroll"
part="form-control"
exportparts="button:button"
slot="suffix"
@click=${this.handleClick}
>
<et2-button-icon
noSubmit
data-direction="1"
name="chevron-up"
part="button"
>
</et2-button-icon>
<et2-button-icon
noSubmit
data-direction="-1"
name="chevron-down"
part="button"
>
</et2-button-icon>
</div>`;
}
}
if(typeof customElements.get("et2-button-scroll") == "undefined")
{
customElements.define("et2-button-scroll", Et2ButtonScroll);
}

View File

@ -120,42 +120,49 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
shoelace, shoelace,
...dateStyles, ...dateStyles,
css` css`
.form-field__group-two { .form-field__group-two {
max-width: 100%; max-width: 100%;
} }
.input-group {
.input-group {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: baseline; align-items: baseline;
} }
.input-group__after {
.input-group__after {
margin-inline-start: var(--sl-input-spacing-medium); margin-inline-start: var(--sl-input-spacing-medium);
} }
et2-select {
et2-select {
color: var(--input-text-color); color: var(--input-text-color);
border-left: 1px solid var(--input-border-color); border-left: 1px solid var(--input-border-color);
flex: 2 1 auto; flex: 2 1 auto;
} }
et2-select::part(control) {
et2-select::part(control) {
border-top-left-radius: 0px; border-top-left-radius: 0px;
border-bottom-left-radius: 0px; border-bottom-left-radius: 0px;
} }
et2-textbox {
.duration__input {
flex: 1 1 auto; flex: 1 1 auto;
max-width: 4.5em; max-width: 4.5em;
margin-right: -2px; margin-right: -2px;
} }
et2-textbox::part(input) {
.duration__input::part(input) {
padding-right: 0px; padding-right: 0px;
} }
et2-textbox:not(:last-child)::part(base) {
.duration__input:not(:last-child)::part(base) {
border-right: none; border-right: none;
border-top-right-radius: 0px; border-top-right-radius: 0px;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
} }
`, `,
]; ];
} }
@ -300,7 +307,7 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
return "" + (this.dataFormat === 'm' ? Math.round(value) : value); return "" + (this.dataFormat === 'm' ? Math.round(value) : value);
} }
let val = this._durationNode.length ? this._durationNode[0].value : ''; let val = this._durationNode.length ? this._durationNode[0].valueAsNumber : '';
if(val === '' || isNaN(val)) if(val === '' || isNaN(val))
{ {
return this.emptyNot0 ? '' : "0"; return this.emptyNot0 ? '' : "0";
@ -565,9 +572,11 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
} }
return html`${inputs.map((input : any) => return html`${inputs.map((input : any) =>
html` html`
<et2-textbox part="duration__${input.name}" type="number" class="duration__input" name=${input.name} <et2-number part="${"duration__" + input.name}" class="duration__input"
min=${input.min} max=${input.max} title=${input.title} exportparts="scroll:scroll,scrollbutton:scrollbutton"
value=${input.value}></et2-textbox>` name=${input.name}
min=${input.min} max=${input.max} precision="2" title=${input.title}
value=${input.value}></et2-number>`
)} )}
`; `;
} }
@ -611,7 +620,7 @@ export class Et2DateDuration extends Et2InputWidget(FormControlMixin(LitElement)
*/ */
get _durationNode() : HTMLInputElement[] get _durationNode() : HTMLInputElement[]
{ {
return this.shadowRoot ? this.shadowRoot.querySelectorAll("et2-textbox") || [] : []; return this.shadowRoot ? this.shadowRoot.querySelectorAll(".duration__input") || [] : [];
} }

View File

@ -9,9 +9,33 @@
*/ */
import {Et2Textbox} from "./Et2Textbox"; import {Et2Textbox} from "./Et2Textbox";
import {css, html, render} from "@lion/core";
export class Et2Number extends Et2Textbox export class Et2Number extends Et2Textbox
{ {
static get styles()
{
return [
...(super.styles ? (Array.isArray(super.styles) ? super.styles : [super.styles]) : []),
css`
/* Scroll buttons */
:host(:hover) ::slotted(et2-button-scroll) {
display: flex;
}
::slotted(et2-button-scroll) {
display: none;
}
.input--medium .input__suffix ::slotted(et2-button-scroll) {
padding: 0px;
}
`,
];
}
static get properties() static get properties()
{ {
return { return {
@ -35,17 +59,33 @@ export class Et2Number extends Et2Textbox
} }
} }
constructor()
{
super();
this.handleScroll = this.handleScroll.bind(this);
}
connectedCallback()
{
super.connectedCallback();
// Add spinners
render(this._incrementButtonTemplate(), this);
}
transformAttributes(attrs) transformAttributes(attrs)
{ {
if (attrs.precision === 0 && typeof attrs.step === 'undefined') if(attrs.precision === 0 && typeof attrs.step === 'undefined')
{ {
attrs.step = 1; attrs.step = 1;
} }
if (typeof attrs.validator === 'undefined') if(typeof attrs.validator === 'undefined')
{ {
attrs.validator = attrs.precision === 0 ? '/^-?[0-9]*$/' : '/^-?[0-9]*[,.]?[0-9]*$/'; attrs.validator = attrs.precision === 0 ? '/^-?[0-9]*$/' : '/^-?[0-9]*[,.]?[0-9]*$/';
} }
attrs.type = 'number'; attrs.inputmode = "numeric";
super.transformAttributes(attrs); super.transformAttributes(attrs);
} }
@ -58,16 +98,38 @@ export class Et2Number extends Et2Textbox
{ {
super.validator = regexp; super.validator = regexp;
} }
get validator() get validator()
{ {
return super.validator; return super.validator;
} }
set_value(val) handleInput()
{ {
if (""+val !== "") // Do nothing
}
handleBlur()
{
this.value = this.input.value;
super.handleBlur();
}
set value(val)
{
if("" + val !== "")
{ {
if (typeof this.precision !== 'undefined') // use decimal separator from user prefs
const format = this.egw().preference('number_format');
const sep = format ? format[0] : '.';
// Remove separator so parseFloat works
if(typeof val === 'string' && format && sep && sep !== '.')
{
val = val.replace(sep, '.');
}
if(typeof this.precision !== 'undefined')
{ {
val = parseFloat(val).toFixed(this.precision); val = parseFloat(val).toFixed(this.precision);
} }
@ -75,33 +137,71 @@ export class Et2Number extends Et2Textbox
{ {
val = parseFloat(val); val = parseFloat(val);
} }
// use decimal separator from user prefs // Put separator back in, if different
const format = this.egw().preference('number_format');
const sep = format ? format[0] : '.';
if(typeof val === 'string' && format && sep && sep !== '.') if(typeof val === 'string' && format && sep && sep !== '.')
{ {
val = val.replace('.', sep); val = val.replace('.', sep);
} }
} }
this.value = val; super.value = val;
} }
getValue() get value()
{ {
let val = this.value; return super.value;
}
if (""+val !== "") getValue() : any
{
// Needs to be string to pass validator
return "" + this.valueAsNumber;
}
get valueAsNumber() : number
{
let val = this.__value;
if("" + val !== "")
{ {
if (typeof this.precision !== 'undefined') // remove decimal separator from user prefs
const format = this.egw().preference('number_format');
const sep = format ? format[0] : '.';
if(typeof val === 'string' && format && sep && sep !== '.')
{ {
val = parseFloat(val).toFixed(this.precision); val = val.replace(sep, '.');
}
if(typeof this.precision !== 'undefined')
{
val = parseFloat(parseFloat(val).toFixed(this.precision));
} }
else else
{ {
val = parseFloat(val); val = parseFloat(val);
} }
} }
return val + ""; return val;
}
private handleScroll(e)
{
const old_value = this.value;
this.value = "" + (this.valueAsNumber + e.detail * (parseFloat(this.step) || 1));
this.dispatchEvent(new CustomEvent("sl-change", {bubbles: true}));
this.requestUpdate("value", old_value);
}
protected _incrementButtonTemplate()
{
// No increment buttons on mobile
if(typeof egwIsMobile == "function" && egwIsMobile())
{
return '';
}
return html`
<et2-button-scroll class="et2-number__scrollbuttons" slot="suffix"
part="scroll"
@et2-scroll=${this.handleScroll}></et2-button-scroll>`;
} }
} }
// @ts-ignore TypeScript is not recognizing that Et2Textbox is a LitElement // @ts-ignore TypeScript is not recognizing that Et2Textbox is a LitElement

View File

@ -0,0 +1,109 @@
/**
* Test file for Etemplate webComponent Textbox
*/
import {assert, fixture, html} from '@open-wc/testing';
import {Et2Number} from "../Et2Number";
import * as sinon from "sinon";
window.egw = {
lang: i => i + "*",
tooltipUnbind: () => {},
preference: () => ""
};
// Reference to component under test
let element : Et2Number;
async function before()
{
// Create an element to test with, and wait until it's ready
element = await fixture<Et2Number>(html`
<et2-number></et2-number>
`);
sinon.stub(element, "egw").returns(window.egw);
return element;
}
describe("Number widget", () =>
{
// Setup run before each test
beforeEach(before);
it('is defined', () =>
{
assert.instanceOf(element, Et2Number);
});
it('has a label', () =>
{
element.set_label("Yay label");
assert.isEmpty(element.shadowRoot.querySelectorAll('.et2_label'));
});
it("handles precision", () =>
{
window.egw.preference = () => ".";
element.precision = 2;
element.value = "1.234";
assert.equal(element.value, "1.23", "Wrong number of decimals");
element.precision = 0;
element.value = "1.234";
assert.equal(element.value, "1", "Wrong number of decimals");
// Now do it with comma decimal separator
window.egw.preference = () => ",";
element.precision = 2;
element.value = "1.234";
assert.equal(element.value, "1,23", "Wrong number of decimals");
element.value = "1,234";
assert.equal(element.value, "1,23", "Wrong number of decimals");
element.precision = 0;
element.value = "1,234";
assert.equal(element.value, "1", "Wrong number of decimals");
})
describe("Check number preferences", () =>
{
const checkValue = (set, expected?) =>
{
if(typeof expected == "undefined")
{
expected = set;
}
element.value = set;
assert.equal(element.value, expected);
};
it("Handles . as decimal", () =>
{
window.egw.preference = () => ".";
checkValue("1");
assert.equal(element.valueAsNumber, 1, "Numeric value does not match");
checkValue("1.1");
assert.equal(element.valueAsNumber, 1.1, "Numeric value does not match");
element.value = "Fail";
assert.isNaN(element.value);
});
it("Handles , as decimal", () =>
{
window.egw.preference = () => ",";
checkValue("1");
assert.equal(element.valueAsNumber, 1, "Numeric value does not match");
checkValue("1,1", "1.1");
assert.equal(element.valueAsNumber, 1.1, "Numeric value does not match");
element.value = "Fail";
assert.isNaN(element.value);
});
});
});
//
// inputBasicTests(before, "I'm a good test value", "input");

View File

@ -32,6 +32,7 @@ import './Et2Avatar/Et2Avatar';
import './Et2Avatar/Et2AvatarGroup'; import './Et2Avatar/Et2AvatarGroup';
import './Et2Button/Et2Button'; import './Et2Button/Et2Button';
import './Et2Button/Et2ButtonIcon'; import './Et2Button/Et2ButtonIcon';
import './Et2Button/Et2ButtonScroll';
import './Et2Button/Et2ButtonTimestamper'; import './Et2Button/Et2ButtonTimestamper';
import './Et2Checkbox/Et2Checkbox'; import './Et2Checkbox/Et2Checkbox';
import './Et2Checkbox/Et2CheckboxReadonly'; import './Et2Checkbox/Et2CheckboxReadonly';