Skip to content

Commit e5f1a01

Browse files
committed
Replace range inputs with custom number input component (#343)
1 parent a4839fc commit e5f1a01

File tree

10 files changed

+188
-87
lines changed

10 files changed

+188
-87
lines changed

frontend/src/i18n/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,11 @@
727727
"delete-description": "To delete this map, type {{code}} into the field and click the “Delete map” button.",
728728
"delete-code": "DELETE"
729729
},
730+
"number-input": {
731+
"integer-error": "Please enter a valid integer.",
732+
"min-error": "Please enter a number not smaller than {{min}}.",
733+
"max-error": "Please enter a number not greater than {{max}}."
734+
},
730735
"pagination": {
731736
"first-label": "First",
732737
"previous-label": "Previous",

frontend/src/lib/components/edit-line-dialog.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,6 @@
123123
<WidthPicker
124124
:id="`${id}-width-input`"
125125
v-model="line.width"
126-
class="fm-custom-range-with-label"
127126
></WidthPicker>
128127
</div>
129128
</div>

frontend/src/lib/components/edit-marker-dialog.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@
115115
<SizePicker
116116
:id="`${id}-size-input`"
117117
v-model="marker.size"
118-
class="fm-custom-range-with-label"
119118
></SizePicker>
120119
</div>
121120
</div>

frontend/src/lib/components/edit-type-dialog/edit-type-dialog.vue

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,6 @@
233233
<SizePicker
234234
:id="`${id}-default-size-input`"
235235
v-model="type.defaultSize"
236-
class="fm-custom-range-with-label"
237236
></SizePicker>
238237
</div>
239238
<div class="col-sm-3">
@@ -315,7 +314,6 @@
315314
<WidthPicker
316315
:id="`${id}-default-width-input`"
317316
v-model="type.defaultWidth"
318-
class="fm-custom-range-with-label"
319317
></WidthPicker>
320318
</div>
321319
<div class="col-sm-3">

frontend/src/lib/components/edit-type-dialog/edit-type-dropdown-dialog.vue

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,6 @@
345345
<SizePicker
346346
:modelValue="option.size ?? type.defaultSize"
347347
@update:modelValue="option.size = $event"
348-
class="fm-custom-range-with-label"
349348
></SizePicker>
350349
</td>
351350
<td v-if="fieldValue.controlIcon" class="field">
@@ -364,7 +363,6 @@
364363
<WidthPicker
365364
:modelValue="option.width ?? type.defaultWidth"
366365
@update:modelValue="option.width = $event"
367-
class="fm-custom-range-with-label"
368366
></WidthPicker>
369367
</td>
370368
<td v-if="fieldValue.controlStroke" class="field">
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<script setup lang="ts">
2+
import type { Validator } from "./validated-form/validated-field.vue";
3+
import ValidatedField from "./validated-form/validated-field.vue";
4+
import { useValidatedModel } from "../../utils/vue";
5+
import { computed } from "vue";
6+
import { useI18n } from "../../utils/i18n";
7+
8+
const props = withDefaults(defineProps<{
9+
validators?: Array<Validator<number>>;
10+
min?: number;
11+
max?: number;
12+
steps?: number[];
13+
id?: string;
14+
}>(), {
15+
steps: () => [1]
16+
});
17+
18+
const i18n = useI18n();
19+
20+
function validateInteger(val: any) {
21+
if (typeof val !== "string" || !val.match(/^\d+$/) || !isFinite(Number(val))) {
22+
return i18n.t("number-input.integer-error");
23+
}
24+
}
25+
26+
function validateMin(val: any) {
27+
if (props.min != null) {
28+
const number = Number(val);
29+
if (isFinite(number) && number < props.min) {
30+
return i18n.t("number-input.min-error", { min: props.min });
31+
}
32+
}
33+
}
34+
35+
function validateMax(val: any) {
36+
if (props.max != null) {
37+
const number = Number(val);
38+
if (isFinite(number) && number > props.max) {
39+
return i18n.t("number-input.max-error", { max: props.max });
40+
}
41+
}
42+
}
43+
44+
const validators = computed(() => [
45+
validateInteger,
46+
validateMin,
47+
validateMax,
48+
...props.validators?.map((v): Validator<string> => (val, signal) => validateInteger(val) == null ? v(Number(val), signal) : undefined) ?? []
49+
]);
50+
51+
const model = defineModel<number>({ required: true });
52+
53+
const value = useValidatedModel({
54+
get: () => `${model.value}`,
55+
set: (val) => {
56+
model.value = Number(val);
57+
},
58+
validators: [validateInteger]
59+
});
60+
61+
const valid = computed(() => validateInteger(value.value) == null);
62+
63+
function handleKeyDown(e: KeyboardEvent) {
64+
let increase: number | undefined;
65+
if (e.key === "ArrowUp" && !e.shiftKey && !e.metaKey && !e.altKey) {
66+
increase = e.ctrlKey ? 5 : 1;
67+
} else if (e.key === "ArrowDown" && !e.shiftKey && !e.metaKey && !e.altKey) {
68+
increase = e.ctrlKey ? -5 : -1;
69+
}
70+
71+
if (increase != null) {
72+
e.preventDefault();
73+
74+
if (valid.value) {
75+
if (increase > 0 && (props.max == null || model.value < props.max)) {
76+
model.value = Math.min(props.max ?? Infinity, model.value + increase);
77+
} else if (increase < 0 && (props.min == null || model.value > props.min)) {
78+
model.value = Math.max(props.min ?? -Infinity, model.value + increase);
79+
}
80+
}
81+
}
82+
}
83+
</script>
84+
85+
<template>
86+
<ValidatedField
87+
:value="value"
88+
:validators="validators"
89+
class="fm-number-input position-relative"
90+
>
91+
<template #default="slotProps">
92+
<div class="input-group has-validation">
93+
<template v-for="step in [...props.steps ?? []].reverse()" :key="step">
94+
<button type="button" class="btn btn-secondary" :disabled="!valid || (props.min != null && model - step < props.min)" @click="model -= step" tabindex="-1">
95+
&minus;{{step === 1 && props.steps?.length === 1 ? "" : `\u202f${step}`}}
96+
</button>
97+
</template>
98+
<input
99+
type="text"
100+
class="form-control"
101+
inputmode="numeric"
102+
v-model="value"
103+
:id="props.id"
104+
:ref="slotProps.inputRef"
105+
@keydown="handleKeyDown"
106+
/>
107+
<template v-for="step in props.steps ?? []" :key="step">
108+
<button type="button" class="btn btn-secondary" :disabled="!valid || (props.max != null && model - step > props.max)" @click="model += step" tabindex="-1">
109+
+{{step === 1 && props.steps?.length === 1 ? "" : `\u202f${step}`}}
110+
</button>
111+
</template>
112+
<div class="invalid-tooltip">
113+
{{slotProps.validationError}}
114+
</div>
115+
</div>
116+
</template>
117+
</ValidatedField>
118+
</template>
119+
120+
<style lang="scss">
121+
.fm-number-input input {
122+
width: 100%;
123+
}
124+
</style>
Lines changed: 12 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,22 @@
11
<script setup lang="ts">
2-
import { computed } from "vue";
3-
import vTooltip from "../../utils/tooltip";
4-
import ValidatedField, { type Validator } from "./validated-form/validated-field.vue";
2+
import { type Validator } from "./validated-form/validated-field.vue";
3+
import NumberInput from "./number-input.vue";
54
65
const props = defineProps<{
7-
modelValue: number;
6+
id?: string;
87
validators?: Array<Validator<number>>;
98
}>();
109
11-
const emit = defineEmits<{
12-
"update:modelValue": [value: number];
13-
}>();
14-
15-
const value = computed({
16-
get: () => props.modelValue,
17-
set: (value) => {
18-
emit("update:modelValue", value!);
19-
}
20-
});
10+
const value = defineModel<number>({ required: true });
2111
</script>
2212

2313
<template>
24-
<ValidatedField
25-
class="fm-size-picker position-relative"
26-
:value="value"
14+
<NumberInput
15+
v-model="value"
16+
class="fm-size-picker"
17+
:id="props.id"
18+
:min="15"
19+
:steps="[1, 5]"
2720
:validators="props.validators"
28-
>
29-
<template #default="slotProps">
30-
<input
31-
type="range"
32-
class="custom-range"
33-
min="15"
34-
v-model.number="value"
35-
:ref="slotProps.inputRef"
36-
v-tooltip="`${value}`"
37-
/>
38-
<div class="invalid-tooltip">
39-
{{slotProps.validationError}}
40-
</div>
41-
</template>
42-
</ValidatedField>
43-
</template>
44-
45-
<style lang="scss">
46-
.fm-size-picker input {
47-
width: 100%;
48-
touch-action: pan-x;
49-
}
50-
</style>
21+
></NumberInput>
22+
</template>

frontend/src/lib/components/ui/validated-form/validated-field.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { ref, watchEffect, computed, watchSyncEffect } from "vue";
2+
import { ref, watchEffect, computed, watchSyncEffect, readonly } from "vue";
33
import { isPromise } from "facilmap-utils";
44
import { useDomEventListener } from "../../../utils/utils";
55
import { getValidatedForm } from "./validated-form.vue";
@@ -172,6 +172,12 @@
172172
function setInputRef(el: any | null): void {
173173
inputRef.value = el as FormElement ?? undefined;
174174
}
175+
176+
const expose = readonly({
177+
isValidating,
178+
validationError: resolvedValidationError
179+
});
180+
defineExpose(expose);
175181
</script>
176182

177183
<template>
Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,21 @@
11
<script setup lang="ts">
2-
import { computed } from "vue";
3-
import vTooltip from "../../utils/tooltip";
42
import type { Validator } from "./validated-form/validated-field.vue";
5-
import ValidatedField from "./validated-form/validated-field.vue";
3+
import NumberInput from "./number-input.vue";
64
75
const props = defineProps<{
8-
modelValue: number;
6+
id?: string;
97
validators?: Array<Validator<number>>;
108
}>();
119
12-
const emit = defineEmits<{
13-
"update:modelValue": [value: number];
14-
}>();
15-
16-
const value = computed({
17-
get: () => props.modelValue,
18-
set: (value) => {
19-
emit("update:modelValue", value!);
20-
}
21-
});
10+
const value = defineModel<number>({ required: true });
2211
</script>
2312

2413
<template>
25-
<ValidatedField
26-
:value="value"
14+
<NumberInput
15+
v-model="value"
16+
:id="props.id"
2717
:validators="props.validators"
28-
class="fm-width-picker position-relative"
29-
>
30-
<template #default="slotProps">
31-
<input
32-
type="range"
33-
class="custom-range"
34-
min="1"
35-
v-model.number="value"
36-
:ref="slotProps.inputRef"
37-
v-tooltip="`${value}`"
38-
/>
39-
<div class="invalid-tooltip">
40-
{{slotProps.validationError}}
41-
</div>
42-
</template>
43-
</ValidatedField>
44-
</template>
45-
46-
<style lang="scss">
47-
.fm-width-picker input {
48-
width: 100%;
49-
touch-action: pan-x;
50-
}
51-
</style>
18+
class="fm-width-picker"
19+
:min="1"
20+
></NumberInput>
21+
</template>

frontend/src/lib/utils/vue.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export function computedOnResize<T>(getValue: () => T): Readonly<Ref<T>> {
3030
return shallowReadonly(value);
3131
}
3232

33+
/**
34+
* Returns a ref that represents the value of a prop but falls back to an internal state if the prop is not specified. This can be used to make a model prop
35+
* optional, so that if a v-model directive is used on the component, its state is persisted in the external model, but if v-model is not used, its state
36+
* is persisted in an internal value.
37+
* @param fallbackValue The initial value if the prop is undefined
38+
* @param getProp A getter for the model value prop. If it returns undefined, the prop is considered not set and the internal value is used instead.
39+
* @param onUpdate This is called when the value is set. This should emit the update:modelValue (or similar) event.
40+
*/
3341
export function useRefWithOverride<Value>(fallbackValue: Value, getProp: () => Value | undefined, onUpdate: (newValue: Value) => void): Ref<Value> {
3442
const internalValue = ref(getProp() ?? fallbackValue);
3543
return computed({
@@ -44,6 +52,28 @@ export function useRefWithOverride<Value>(fallbackValue: Value, getProp: () => V
4452
});
4553
}
4654

55+
/**
56+
* Returns a ref that represents the internal value of a form field whose value is only applied to a model if it passes a certain validation. An example for this would be
57+
* a text field that is bound to a number field. The user should be able to type in the field freely, even if the value is temporarily not a valid number (such as an empty
58+
* string or a number ending in a decimal point), but the value should only be applied to a model of type `number` when it actually is a valid number.
59+
* options.set() is only called with validated values.
60+
*/
61+
export function useValidatedModel<Value>(options: { get: () => Value; set: (newValue: Value) => void; validators: Array<(value: Value) => (string | undefined)> }): Ref<Value> {
62+
const internalValue = ref(options.get());
63+
watch(() => options.get(), (val) => {
64+
internalValue.value = val;
65+
});
66+
return computed({
67+
get: () => internalValue.value,
68+
set: (val: Value) => {
69+
internalValue.value = val;
70+
if (options.validators.every((v) => v(val) == null)) {
71+
options.set(val);
72+
}
73+
}
74+
});
75+
}
76+
4777
export function mapRef<K>(map: Map<K, Element | ComponentPublicInstance>, key: K): (ref: Element | ComponentPublicInstance | null) => void {
4878
return (ref) => {
4979
if (ref) {

0 commit comments

Comments
 (0)