Skip to content

Commit 6becb8f

Browse files
committed
InputScientificNumber: added support for up/down buttons.
1 parent 31ee41a commit 6becb8f

File tree

3 files changed

+325
-14
lines changed

3 files changed

+325
-14
lines changed

src/renderer/src/common/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export const LONG_DELAY: number = 369;
88

99
export const TOAST_LIFE: number = 3000;
1010

11+
export const SPIN_INITIAL_DELAY: number = 500;
12+
export const SPIN_REPEAT_DELAY: number = 40;
13+
1114
const crtYear: number = new Date().getFullYear();
1215

1316
export const COPYRIGHT: string = crtYear === 2025 ? '2025' : `2025-${String(crtYear)}`;

src/renderer/src/components/widgets/InputScientificNumber.vue

Lines changed: 319 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,160 @@
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">
1658
import * as vue from 'vue';
1759
60+
import * as constants from '../../common/constants.ts';
61+
62+
defineOptions({
63+
inheritAttrs: false
64+
});
65+
1866
const props = defineProps<{
1967
modelValue: number | undefined;
2068
label: string;
2169
allowEmpty?: boolean;
70+
min?: number;
71+
max?: number;
72+
step?: number;
2273
}>();
2374
2475
const emit = defineEmits<(event: 'update:modelValue', value: number | undefined) => void>();
2576
77+
const attrs = vue.useAttrs();
2678
const internalValue = vue.ref<string>('');
2779
const 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
29159
const 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
131264
const 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>

src/renderer/src/components/widgets/InputWidget.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<div v-else>
1616
<InputScientificNumber v-model="value"
1717
:label="name"
18+
:min="minimumValue"
19+
:max="maximumValue"
20+
:step="compStepValue"
1821
size="small"
1922
@update:model-value="inputTextValueUpdated"
2023
/>

0 commit comments

Comments
 (0)