import type { VNode } from 'vue'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import { Prop, toNative } from 'vue-facing-decorator'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import FormItemWrapper, { HintType } from '../form/form-item-wrapper'; import { InputSpinner } from './ts/bootstrapInputSpinner'; import './css/input-spinner.css'; export enum NumericInputMode { Clasic = 'clasic', Spinner = 'spinner', } // TODO: Turn into approperiate types interface NumericInputArgs extends Omit { value: number; maxValue?: number; minValue?: number; fullWidth?: boolean; step?: number; updateMode?: 'input' | 'change'; placeholder?: string; decimalsAlwaysVisible?: boolean; /** * QA_AT-24: when set (Classic mode only), the input clamps the visible * value to at most this many decimal places AS THE USER TYPES — not only * on blur / save. Excess decimal characters are stripped via string * truncation (NOT numeric rounding) so digits the operator typed are * never silently rewritten; the DOM `input.value` is rewritten in place. * Intermediate states like `12.` are preserved so the operator can * continue typing. * * The `changed` callback respects `updateMode`: under the default * (change-on-blur) it is NOT fired per-keystroke even when truncation * happens — the trailing native `change` on blur reads the truncated DOM * value and propagates it. Callers that want per-keystroke updates must * opt in with `updateMode='input'`. * * Undefined = legacy unbounded-precision behaviour preserved for all * existing callers. Spinner mode ignores this prop (Spinner editor * already enforces decimals via Intl.NumberFormat on blur). */ maxDecimals?: number; changed: (newValue: number) => void; mode?: NumericInputMode; disabled?: boolean; /** Accessible name when there is no visible `label` (WCAG 4.1.2 / 3.3.2). */ ariaLabel?: string; } @Component class NumericInputComponent extends TsxComponent implements NumericInputArgs { @Prop() label!: string | VNode; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() subtitle!: string; @Prop() value!: number; @Prop() mandatory!: boolean; @Prop() placeholder!: string; @Prop() changed!: (newValue: number) => void; @Prop() maxValue!: number; @Prop() minValue!: number; @Prop() fullWidth!: boolean; @Prop() wrap!: boolean; @Prop() step!: number; @Prop() hint: string; @Prop() hintType: HintType; @Prop() marginType?: MarginType; @Prop() maxWidth?: number; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() showClearValueButton!: boolean; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() decimalsAlwaysVisible!: boolean; @Prop() maxDecimals?: number; @Prop() mode: NumericInputMode; @Prop() updateMode?: 'input' | 'change'; @Prop() disabled?: boolean; @Prop() cssClass?: string; /** * Accessible name for the numeric field. Use when there is no visible * `label` (e.g. a quantity stepper next to a product name) so the control * is not announced as an unnamed edit field — WCAG 4.1.2 / 3.3.2. In Spinner * mode this is mirrored onto the visible spinner input by InputSpinner. */ @Prop() ariaLabel?: string; private get errorId(): string { return `numeric-error-${this.$.uid}`; } raiseChangeEvent(e) { this.populateValidationDeclaration(); if (this.changed != null) { let newValue = e.target.value; if (newValue == null || newValue === '') { newValue = null; } else { try { newValue = Number(newValue); if (isNaN(newValue)) { newValue = null; } } catch (e) { newValue = null; } } this.changed(newValue); } } handleClassicInput(e) { if (this.decimalsAlwaysVisible == true) { const value = parseFloat(e.value); const decimals = this.getDecimals(); if (decimals > 0 && !isNaN(value)) { (this.$el as HTMLElement).querySelector('input').value = value.toFixed(decimals); } } } /** * QA_AT-24: when `maxDecimals` is set and the typed value carries more * decimal places than allowed, rewrite the DOM `input.value` to the * truncated representation. Returns the truncated numeric value, or * `null` when no truncation happened or the input is not yet a complete * number — e.g. the intermediate state `12.` keeps its trailing dot so * the operator can continue typing. * * Non-destructive for intermediate states: typing `12.` leaves `12.` in * the DOM; only EXCESS decimal characters past `maxDecimals` are * stripped. Uses string truncation (NOT numeric rounding) so digits the * operator already typed are never silently rewritten — the save-side * `parseFloat(e.toFixed(6))` in GpsInput / GpsBoundingBoxInput remains as * a defence-in-depth rounding net for non-input paths. */ private clampToMaxDecimals(inputEl: HTMLInputElement): number | null { if (this.maxDecimals == null || this.maxDecimals < 0) { return null; } const raw = inputEl.value; if (raw == null || raw === '') { return null; } // Preserve trailing "." and partial inputs like "12." — they have a // length-0 decimal substring which is <= cap. const dotIndex = raw.indexOf('.'); if (dotIndex < 0) { return null; } const decimalPart = raw.slice(dotIndex + 1); if (decimalPart.length <= this.maxDecimals) { return null; } const truncatedString = raw.slice(0, dotIndex + 1 + this.maxDecimals); inputEl.value = truncatedString; const parsed = parseFloat(truncatedString); return isNaN(parsed) ? null : parsed; } getMode(): NumericInputMode { if (this.mode != null) { return this.mode; } else { return NumericInputMode.Spinner; } } getValue(): number | null { return this.value ?? null; } getDecimals(): number { if (this.step != null) { const step: string = String(this.step); const dec = step.split('.', 2)[1]?.length; return dec || 0; } else { return 0; } } /** * QA_AT-11: clamp a committed numeric model value to at most `maxDecimals` * decimal places for DISPLAY — applied in `render()` (initial paint) and * `updated()` (re-render) so an UNFOCUSED input never shows more decimals * than allowed, regardless of how the value reached the model (map * right-click, programmatic prefill, paste, legacy data). Without this the * QA bounce reproduced: a raw 15-decimal map-click longitude rendered * verbatim because both rounding layers (typing-time onInput clamp, * blur-time `changed` round) are bypassed by a programmatic model write. * * Uses STRING TRUNCATION (NOT numeric rounding) to stay consistent with the * QA_AT-24 typing-time `clampToMaxDecimals` contract (`49.0631912345` → * `49.063191`, never `49.063192`). Opt-in: returns the value untouched when * `maxDecimals` is unset, so non-GPS NumericInputs are unaffected. The * in-progress typed value is never stomped — both callers only apply this to * UNFOCUSED inputs. */ private formatDisplayValue(value: number | null): string { if (value == null) { return ''; } const raw = String(value); if (this.maxDecimals == null || this.maxDecimals < 0) { return raw; } const dotIndex = raw.indexOf('.'); if (dotIndex < 0) { return raw; } const decimalPart = raw.slice(dotIndex + 1); if (decimalPart.length <= this.maxDecimals) { return raw; } return raw.slice(0, dotIndex + 1 + this.maxDecimals); } render(h) { const mode = this.getMode(); if (mode == NumericInputMode.Clasic) { let inputValue = this.getValue(); if (this.decimalsAlwaysVisible == true && inputValue != null) { inputValue = inputValue.toFixed(this.getDecimals()) as any; } else if (this.maxDecimals != null && inputValue != null) { // QA_AT-11: clamp the initial displayed value to maxDecimals so a // raw model value (e.g. map-click longitude) never paints >6 // decimals. String truncation, opt-in — see formatDisplayValue. inputValue = this.formatDisplayValue(inputValue) as any; } return ( this.raiseChangeEvent(e)} onInput={(e) => { // QA_AT-24: input-time decimal clamp. Always trims the DOM // `input.value` when `maxDecimals` is exceeded so the operator // never sees more digits than allowed; the `changed` callback // however respects `updateMode` — under the default // change-on-blur contract the trailing native `change` reads // the truncated DOM value and propagates it, so we don't fire // per-keystroke (which previously caused mid-typing reactive // storms on `gpsModel.latitude` → map re-centering → // validation re-runs). if (this.maxDecimals != null && this.maxDecimals >= 0) { const truncated = this.clampToMaxDecimals(e.target as HTMLInputElement); if (truncated != null && this.changed != null && this.updateMode == 'input') { this.changed(truncated); } } if (this.updateMode == 'input') { this.handleClassicInput(e); } }} class={PowerduckState.getFormControlCssClass()} placeholder={this.placeholder} disabled={this.disabled as any != true ? null : 'disabled'} aria-label={this.ariaLabel || undefined} aria-invalid={this.hasValidationError ? 'true' : undefined} aria-describedby={this.hasValidationError ? this.errorId : undefined} aria-required={this.mandatory ? 'true' : undefined} /> ); } else { const decimals = this.getDecimals(); return (
this.raiseChangeEvent(e)} onInput={(e) => { if (this.updateMode == 'input') { this.raiseChangeEvent(e); } }} class={`${PowerduckState.getFormControlCssClass()} input-spinner`} data-decimals={decimals} disabled={this.disabled as any != true ? null : 'disabled'} aria-label={this.ariaLabel || undefined} aria-invalid={this.hasValidationError ? 'true' : undefined} aria-describedby={this.hasValidationError ? this.errorId : undefined} aria-required={this.mandatory ? 'true' : undefined} />
); } } mounted() { const mode = this.getMode(); if (mode == NumericInputMode.Spinner) { // eslint-disable-next-line no-new new InputSpinner(this.$el.querySelector('input')); } } updated() { const newValue = this.getValue(); const isClassic = this.getMode() == NumericInputMode.Clasic; const applyDecimals = isClassic && this.decimalsAlwaysVisible == true && newValue != null; const decimals = applyDecimals ? this.getDecimals() : 0; // QA_AT-11: when maxDecimals is set (Classic GPS / bbox inputs) and // decimalsAlwaysVisible is not, truncate the re-rendered unfocused value // so a raw model value never persists >maxDecimals decimals in the DOM. // String truncation, opt-in — see formatDisplayValue. The focus guard // below still protects the in-progress typed value. const applyMaxDecimals = isClassic && !applyDecimals && this.maxDecimals != null && newValue != null; let renderedValue: string | number; if (applyDecimals) { renderedValue = newValue.toFixed(decimals); } else if (applyMaxDecimals) { renderedValue = this.formatDisplayValue(newValue); } else { renderedValue = newValue ?? ''; } (this.$el as HTMLElement)?.querySelectorAll('input').forEach((input) => { // Don't stomp the in-progress value while the user is typing. The Spinner-mode // value interceptor + InputSpinner editor would otherwise re-format `input.value` // to `Intl.NumberFormat(...)` then a follow-up re-render snaps it back to the // stale prop — QA_GO-429 Bug B. const active = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLElement | null; const isThisInputFocused = active === input || (active != null && active.closest('.input-spinner-wrap')?.contains(input) === true); if (isThisInputFocused) { return; } input.value = String(renderedValue); }); } } const NumericInput = toNative(NumericInputComponent); export default NumericInput;