11<template >
2- <FloatLabel variant =" on" >
3- <InputText :model-value =" internalValue" class =" w-full"
4- v-bind =" $attrs"
5- v-keyfilter =" { pattern: /^[+-]?(\d*(\.\d*)?|\.\d*)([eE][+-]?\d*)?$/, validateOnly: true }"
6- @update:model-value =" onUpdateModelValue"
7- @blur =" onBlur"
8- @keydown.enter =" onKeydownEnter"
9- @paste =" onPaste"
10- />
2+ <FloatLabel variant =" on" :class =" rootAttrs.class" :style =" rootAttrs.style" >
3+ <span :class =" rootClass" >
4+ <InputText ref =" inputElement" :model-value =" internalValue" class =" p-inputnumber-input w-full"
5+ v-bind =" inputAttrs"
6+ role =" spinbutton"
7+ :aria-valuenow =" currentNumericValue ?? undefined"
8+ :aria-valuemin =" props.min ?? undefined"
9+ :aria-valuemax =" props.max ?? undefined"
10+ v-keyfilter =" { pattern: /^[+-]?(\d*(\.\d*)?|\.\d*)([eE][+-]?\d*)?$/, validateOnly: true }"
11+ @update:model-value =" onUpdateModelValue"
12+ @focus =" onFocus"
13+ @blur =" onBlur"
14+ @keydown =" onInputKeydown"
15+ @keydown.enter =" onKeydownEnter"
16+ @paste =" onPaste"
17+ />
18+ <span class =" p-inputnumber-button-group" >
19+ <button :class =" incrementButtonClass"
20+ type =" button"
21+ :disabled =" disabled || readonly || maxBoundary"
22+ :tabindex =" -1"
23+ aria-label =" Increment value"
24+ @mousedown =" onSpinButtonMouseDown($event, 1)"
25+ @mouseleave =" clearSpinTimerAndRemoveEventListeners"
26+ @mouseup =" clearSpinTimerAndRemoveEventListeners"
27+ >
28+ <svg width =" 14" height =" 14" viewBox =" 0 0 14 14" fill =" none" xmlns =" http://www.w3.org/2000/svg" class =" p-icon"
29+ aria-hidden =" true" data-pc-section =" incrementicon" >
30+ <path
31+ d =" M10.4134 9.49931C10.3148 9.49977 10.2172 9.48055 10.1262 9.44278C10.0352 9.405 9.95263 9.34942 9.88338 9.27931L6.88338 6.27931L3.88338 9.27931C3.73811 9.34946 3.57409 9.3709 3.41567 9.34044C3.25724 9.30999 3.11286 9.22926 3.00395 9.11025C2.89504 8.99124 2.82741 8.84028 2.8111 8.67978C2.79478 8.51928 2.83065 8.35781 2.91338 8.21931L6.41338 4.71931C6.55401 4.57886 6.74463 4.49997 6.94338 4.49997C7.14213 4.49997 7.33276 4.57886 7.47338 4.71931L10.9734 8.21931C11.1138 8.35994 11.1927 8.55056 11.1927 8.74931C11.1927 8.94806 11.1138 9.13868 10.9734 9.27931C10.9007 9.35315 10.8132 9.41089 10.7168 9.44879C10.6203 9.48669 10.5169 9.5039 10.4134 9.49931Z"
32+ fill =" currentColor" ></path >
33+ </svg >
34+ </button >
35+ <button :class =" decrementButtonClass"
36+ type =" button"
37+ :disabled =" disabled || readonly || minBoundary"
38+ :tabindex =" -1"
39+ aria-label =" Decrement value"
40+ @mousedown =" onSpinButtonMouseDown($event, -1)"
41+ @mouseleave =" clearSpinTimerAndRemoveEventListeners"
42+ @mouseup =" clearSpinTimerAndRemoveEventListeners"
43+ >
44+ <svg width =" 14" height =" 14" viewBox =" 0 0 14 14" fill =" none" xmlns =" http://www.w3.org/2000/svg" class =" p-icon"
45+ aria-hidden =" true" data-pc-section =" decrementicon" >
46+ <path
47+ d =" M3.58659 4.5007C3.68513 4.50023 3.78277 4.51945 3.87379 4.55723C3.9648 4.59501 4.04735 4.65058 4.11659 4.7207L7.11659 7.7207L10.1166 4.7207C10.2619 4.65055 10.4259 4.62911 10.5843 4.65956C10.7427 4.69002 10.8871 4.77074 10.996 4.88976C11.1049 5.00877 11.1726 5.15973 11.1889 5.32022C11.2052 5.48072 11.1693 5.6422 11.0866 5.7807L7.58659 9.2807C7.44597 9.42115 7.25534 9.50004 7.05659 9.50004C6.85784 9.50004 6.66722 9.42115 6.52659 9.2807L3.02659 5.7807C2.88614 5.64007 2.80725 5.44945 2.80725 5.2507C2.80725 5.05195 2.88614 4.86132 3.02659 4.7207C3.09932 4.64685 3.18675 4.58911 3.28322 4.55121C3.37969 4.51331 3.48305 4.4961 3.58659 4.5007Z"
48+ fill =" currentColor" ></path >
49+ </svg >
50+ </button >
51+ </span >
52+ </span >
1153 <label >{{ label }}</label >
1254 </FloatLabel >
1355</template >
1456
1557<script setup lang="ts">
1658import * as vue from ' vue' ;
1759
60+ import * as constants from ' ../../common/constants.ts' ;
61+
62+ defineOptions ({
63+ inheritAttrs: false
64+ });
65+
1866const props = defineProps <{
1967 modelValue: number | undefined ;
2068 label: string ;
2169 allowEmpty? : boolean ;
70+ min? : number ;
71+ max? : number ;
72+ step? : number ;
2273}>();
2374
2475const emit = defineEmits <(event : ' update:modelValue' , value : number | undefined ) => void >();
2576
77+ const attrs = vue .useAttrs ();
2678const internalValue = vue .ref <string >(' ' );
2779const isEditing = vue .ref <boolean >(false );
80+ const isFocused = vue .ref <boolean >(false );
81+ const inputElement = vue .ref <{ $el: HTMLInputElement } | null >(null );
82+ const rootAttrs = vue .computed (() => {
83+ return {
84+ class: attrs .class ,
85+ style: attrs .style
86+ };
87+ });
88+ const inputAttrs = vue .computed (() => {
89+ const { class : _class, style : _style, ... rest } = attrs ;
90+
91+ return rest ;
92+ });
93+ const disabled = vue .computed (() => {
94+ return attrs .disabled === ' ' || attrs .disabled === true || attrs .disabled === ' true' ;
95+ });
96+ const readonly = vue .computed (() => {
97+ return attrs .readonly === ' ' || attrs .readonly === true || attrs .readonly === ' true' ;
98+ });
99+ const invalid = vue .computed (() => {
100+ return attrs .invalid === ' ' || attrs .invalid === true || attrs .invalid === ' true' ;
101+ });
102+ const hasValue = vue .computed (() => {
103+ return internalValue .value !== ' ' ;
104+ });
105+ const stepValue = vue .computed (() => {
106+ return props .step ?? 1 ;
107+ });
108+ const rootClass = vue .computed (() => {
109+ return [
110+ ' p-inputnumber' ,
111+ ' p-component' ,
112+ ' p-inputwrapper' ,
113+ ' p-inputnumber-stacked' ,
114+ ' p-inputnumber-fluid' ,
115+ ' w-full' ,
116+ {
117+ ' p-invalid' : invalid .value ,
118+ ' p-inputwrapper-filled' : hasValue .value ,
119+ ' p-inputwrapper-focus' : isFocused .value
120+ }
121+ ];
122+ });
123+ const currentNumericValue = vue .computed (() => {
124+ if (internalValue .value === ' ' ) {
125+ return props .modelValue ;
126+ }
127+
128+ const value = Number (internalValue .value );
129+
130+ return Number .isFinite (value ) ? value : props .modelValue ;
131+ });
132+ const minBoundary = vue .computed (() => {
133+ return props .min !== undefined && currentNumericValue .value !== undefined && currentNumericValue .value <= props .min ;
134+ });
135+ const maxBoundary = vue .computed (() => {
136+ return props .max !== undefined && currentNumericValue .value !== undefined && currentNumericValue .value >= props .max ;
137+ });
138+ const incrementButtonClass = vue .computed (() => {
139+ return [
140+ ' cursor-pointer' ,
141+ ' p-inputnumber-button' ,
142+ ' p-inputnumber-increment-button' ,
143+ {
144+ ' p-disabled' : maxBoundary .value
145+ }
146+ ];
147+ });
148+ const decrementButtonClass = vue .computed (() => {
149+ return [
150+ ' cursor-pointer' ,
151+ ' p-inputnumber-button' ,
152+ ' p-inputnumber-decrement-button' ,
153+ {
154+ ' p-disabled' : minBoundary .value
155+ }
156+ ];
157+ });
28158
29159const updateInternalValue = (value : number | undefined ) => {
30160 internalValue .value = value !== undefined ? String (value ) : ' ' ;
@@ -98,8 +228,9 @@ const onPaste = (event: ClipboardEvent) => {
98228 onUpdateModelValue (newValue );
99229};
100230
101- const parseAndEmit = () => {
231+ const onBlur = () => {
102232 isEditing .value = false ;
233+ isFocused .value = false ;
103234
104235 if (internalValue .value === ' ' ) {
105236 updateInternalValue (props .modelValue );
@@ -119,16 +250,190 @@ const parseAndEmit = () => {
119250 return ;
120251 }
121252
122- updateInternalValue (parsedValue );
253+ const clampedValue = validateValue (parsedValue );
254+
255+ updateInternalValue (clampedValue );
123256
124- emit (' update:modelValue' , parsedValue );
257+ emit (' update:modelValue' , clampedValue );
125258};
126259
127- const onBlur = () => {
128- parseAndEmit () ;
260+ const onFocus = () => {
261+ isFocused . value = true ;
129262};
130263
131264const onKeydownEnter = (event : KeyboardEvent ) => {
132265 (event .target as HTMLInputElement ).blur ();
133266};
267+
268+ const onInputKeydown = (event : KeyboardEvent ) => {
269+ switch (event .key ) {
270+ case ' ArrowUp' :
271+ if (spin (1 )) {
272+ event .preventDefault ();
273+ }
274+
275+ break ;
276+ case ' ArrowDown' :
277+ if (spin (- 1 )) {
278+ event .preventDefault ();
279+ }
280+
281+ break ;
282+ case ' Home' :
283+ if (props .min !== undefined ) {
284+ isEditing .value = false ;
285+
286+ updateInternalValue (props .min );
287+
288+ emit (' update:modelValue' , props .min );
289+
290+ event .preventDefault ();
291+ }
292+
293+ break ;
294+ case ' End' :
295+ if (props .max !== undefined ) {
296+ isEditing .value = false ;
297+
298+ updateInternalValue (props .max );
299+
300+ emit (' update:modelValue' , props .max );
301+
302+ event .preventDefault ();
303+ }
304+
305+ break ;
306+ default :
307+ break ;
308+ }
309+ };
310+
311+ const validateValue = (value : number ) => {
312+ if (props .min !== undefined && value < props .min ) {
313+ return props .min ;
314+ }
315+
316+ if (props .max !== undefined && value > props .max ) {
317+ return props .max ;
318+ }
319+
320+ return value ;
321+ };
322+
323+ const getFractionDigits = (value : number ) => {
324+ const [coefficient, exponentString] = value .toString ().toLowerCase ().split (' e' );
325+ const exponent = exponentString ? Number (exponentString ) : 0 ;
326+ const coefficientFractionDigits = coefficient .includes (' .' ) ? (coefficient .split (' .' )[1 ]?.length ?? 0 ) : 0 ;
327+
328+ return Math .max (0 , coefficientFractionDigits - exponent );
329+ };
330+
331+ const addWithPrecision = (base : number , increment : number ) => {
332+ const precision = 10 ** Math .max (getFractionDigits (base ), getFractionDigits (increment ));
333+
334+ return Math .round ((base + increment ) * precision ) / precision ;
335+ };
336+
337+ const spinTimer = vue .ref <number | undefined >(undefined );
338+
339+ const spin = (direction : 1 | -1 ): boolean => {
340+ if (disabled .value || readonly .value ) {
341+ return false ;
342+ }
343+
344+ const inferredBaseValue = internalValue .value === ' ' ? (props .modelValue ?? props .min ?? 0 ) : Number (internalValue .value );
345+ const safeCurrentValue = Number .isFinite (inferredBaseValue ) ? inferredBaseValue : (props .modelValue ?? props .min ?? 0 );
346+ const nextValue = validateValue (addWithPrecision (safeCurrentValue , stepValue .value * direction ));
347+
348+ if (nextValue === safeCurrentValue ) {
349+ return false ;
350+ }
351+
352+ isEditing .value = false ;
353+
354+ updateInternalValue (nextValue );
355+
356+ emit (' update:modelValue' , nextValue );
357+
358+ return true ;
359+ };
360+
361+ const clearSpinTimer = () => {
362+ if (spinTimer .value !== undefined ) {
363+ window .clearTimeout (spinTimer .value );
364+
365+ spinTimer .value = undefined ;
366+ }
367+ };
368+
369+ const repeatSpin = (direction : 1 | -1 , interval = constants .SPIN_INITIAL_DELAY ) => {
370+ if (disabled .value || readonly .value ) {
371+ return ;
372+ }
373+
374+ clearSpinTimer ();
375+
376+ if (! spin (direction )) {
377+ return ;
378+ }
379+
380+ spinTimer .value = window .setTimeout (() => {
381+ repeatSpin (direction , constants .SPIN_REPEAT_DELAY );
382+ }, interval );
383+ };
384+
385+ const clearSpinTimerAndRemoveEventListeners = () => {
386+ clearSpinTimer ();
387+
388+ window .removeEventListener (' mouseup' , clearSpinTimerAndRemoveEventListeners );
389+ window .removeEventListener (' pointerup' , clearSpinTimerAndRemoveEventListeners );
390+ window .removeEventListener (' blur' , clearSpinTimerAndRemoveEventListeners );
391+ };
392+
393+ const onSpinButtonMouseDown = (event : MouseEvent , direction : 1 | -1 ) => {
394+ if (disabled .value || readonly .value || event .button !== 0 ) {
395+ return ;
396+ }
397+
398+ inputElement .value ?.$el .focus ();
399+
400+ window .addEventListener (' mouseup' , clearSpinTimerAndRemoveEventListeners );
401+ window .addEventListener (' pointerup' , clearSpinTimerAndRemoveEventListeners );
402+ window .addEventListener (' blur' , clearSpinTimerAndRemoveEventListeners );
403+
404+ repeatSpin (direction );
405+
406+ event .preventDefault ();
407+ };
408+
409+ vue .onBeforeUnmount (() => {
410+ clearSpinTimerAndRemoveEventListeners ();
411+ });
134412 </script >
413+
414+ <style scoped>
415+ .p-inputnumber {
416+ display : inline-flex ;
417+ position : relative ;
418+ }
419+
420+ .p-inputnumber-button {
421+ display : flex ;
422+ justify-content : center ;
423+ width : 1.5rem ;
424+ }
425+
426+ .p-inputnumber-input {
427+ padding-right : 1.5rem ;
428+ }
429+
430+ .p-inputnumber-stacked .p-inputnumber-button-group {
431+ position : absolute ;
432+ inset-inline-end : 0 ;
433+ height : 100% ;
434+ display : flex ;
435+ flex : 1 ;
436+ flex-direction : column ;
437+ justify-content : center ;
438+ }
439+ </style >
0 commit comments