@milaboratories/uikit 2.3.24 → 2.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,32 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Number input field with increment/decrement buttons, validation, and min/max constraints.
4
+ *
5
+ * @example
6
+ * <PlNumberField v-model="price" :step="0.01" :min-value="0" label="Price" />
7
+ *
8
+ * @example
9
+ * <PlNumberField
10
+ * v-model="evenNumber"
11
+ * :validate="(v) => v % 2 !== 0 ? 'Number must be even' : undefined"
12
+ * :update-on-enter-or-click-outside="true"
13
+ * label="Even Number"
14
+ * />
15
+ */
16
+ export default {
17
+ name: 'PlNumberField',
18
+ };
19
+ </script>
20
+
1
21
  <script setup lang="ts">
2
22
  import './pl-number-field.scss';
3
23
  import DoubleContour from '../../utils/DoubleContour.vue';
4
24
  import { useLabelNotch } from '../../utils/useLabelNotch';
5
25
  import { computed, ref, useSlots, watch } from 'vue';
6
26
  import { PlTooltip } from '../PlTooltip';
27
+ import { parseNumber } from './parseNumber';
7
28
 
8
- type NumberInputProps = {
29
+ const props = withDefaults(defineProps<{
9
30
  /** Input is disabled if true */
10
31
  disabled?: boolean;
11
32
  /** Label on the top border of the field, empty by default */
@@ -26,9 +47,7 @@ type NumberInputProps = {
26
47
  errorMessage?: string;
27
48
  /** Additional validity check for input value that must return an error text if failed */
28
49
  validate?: (v: number) => string | undefined;
29
- };
30
-
31
- const props = withDefaults(defineProps<NumberInputProps>(), {
50
+ }>(), {
32
51
  step: 1,
33
52
  label: undefined,
34
53
  placeholder: undefined,
@@ -42,93 +61,71 @@ const props = withDefaults(defineProps<NumberInputProps>(), {
42
61
 
43
62
  const modelValue = defineModel<number | undefined>({ required: true });
44
63
 
45
- const root = ref<HTMLElement>();
46
64
  const slots = useSlots();
47
- const input = ref<HTMLInputElement>();
48
65
 
49
- useLabelNotch(root);
66
+ const rootRef = ref<HTMLElement>();
67
+ const inputRef = ref<HTMLInputElement>();
68
+
69
+ useLabelNotch(rootRef);
50
70
 
51
71
  function modelToString(v: number | undefined) {
52
72
  return v === undefined ? '' : String(+v); // (+v) to avoid staying in input non-number values if they are provided in model
53
73
  }
54
74
 
55
- function isPartial(v: string) {
56
- return v === '.' || v === ',' || v === '-';
57
- }
58
- function stringToModel(v: string) {
59
- if (v === '') {
60
- return undefined;
61
- }
62
- if (isPartial(v)) {
63
- return 0;
64
- }
65
- let forParsing = v;
66
- forParsing = forParsing.replace(',', '.');
67
- forParsing = forParsing.replace('−', '-'); // minus, replacing for the case of input the whole copied value
68
- forParsing = forParsing.replace('–', '-'); // dash, replacing for the case of input the whole copied value
69
- forParsing = forParsing.replace('+', '');
70
- return parseFloat(forParsing);
71
- }
75
+ const parsedResult = computed(() => parseNumber(props, inputValue.value));
72
76
 
73
- const innerTextValue = ref(modelToString(modelValue.value));
74
- const innerNumberValue = computed(() => stringToModel(innerTextValue.value));
77
+ const cachedValue = ref<string | undefined>(undefined);
75
78
 
76
- watch(() => modelValue.value, (outerValue) => { // update inner value if outer value is changed
77
- if (parseFloat(innerTextValue.value) !== outerValue) {
78
- innerTextValue.value = modelToString(outerValue);
79
+ const resetCachedValue = () => cachedValue.value = undefined;
80
+
81
+ watch(modelValue, (n) => {
82
+ const r = parsedResult.value;
83
+ if (r.error || n !== r.value) {
84
+ resetCachedValue();
79
85
  }
80
86
  });
81
87
 
82
- const NUMBER_REGEX = /^[-−–+]?(\d+)?[\\.,]?(\d+)?$/; // parseFloat works without errors on strings with multiple dots, or letters in value
83
88
  const inputValue = computed({
84
89
  get() {
85
- return innerTextValue.value;
90
+ return cachedValue.value ?? modelToString(modelValue.value);
86
91
  },
87
92
  set(nextValue: string) {
88
- const parsedValue = stringToModel(nextValue);
89
- // we allow to set empty value or valid numeric value, otherwise reset input value to previous valid
90
- if (parsedValue === undefined
91
- || (nextValue.match(NUMBER_REGEX) && !isNaN(parsedValue))
92
- ) {
93
- innerTextValue.value = nextValue;
94
- if (!props.updateOnEnterOrClickOutside && !isPartial(nextValue)) { // to avoid applying '-' or '.'
95
- applyChanges();
96
- }
97
- } else if (input.value) {
98
- input.value.value = innerTextValue.value;
93
+ const r = parseNumber(props, nextValue);
94
+
95
+ cachedValue.value = r.cleanInput;
96
+
97
+ if (r.error || props.updateOnEnterOrClickOutside) {
98
+ inputRef.value!.value = r.cleanInput;
99
+ } else {
100
+ modelValue.value = r.value;
99
101
  }
100
102
  },
101
103
  });
104
+
102
105
  const focused = ref(false);
103
106
 
104
107
  function applyChanges() {
105
- if (innerTextValue.value === '') {
106
- modelValue.value = undefined;
107
- return;
108
+ if (parsedResult.value.error === undefined) {
109
+ modelValue.value = parsedResult.value.value;
108
110
  }
109
- modelValue.value = innerNumberValue.value;
110
111
  }
111
112
 
112
113
  const errors = computed(() => {
113
114
  let ers: string[] = [];
115
+
114
116
  if (props.errorMessage) {
115
117
  ers.push(props.errorMessage);
116
118
  }
117
- const parsedValue = innerNumberValue.value;
118
- if (parsedValue !== undefined && isNaN(parsedValue)) {
119
- ers.push('Value is not a number');
120
- } else if (props.validate && parsedValue !== undefined) {
121
- const error = props.validate(parsedValue);
119
+
120
+ const r = parsedResult.value;
121
+
122
+ if (r.error) {
123
+ ers.push(r.error.message);
124
+ } else if (props.validate && r.value !== undefined) {
125
+ const error = props.validate(r.value);
122
126
  if (error) {
123
127
  ers.push(error);
124
128
  }
125
- } else {
126
- if (props.minValue !== undefined && parsedValue !== undefined && parsedValue < props.minValue) {
127
- ers.push(`Value must be higher than ${props.minValue}`);
128
- }
129
- if (props.maxValue !== undefined && parsedValue !== undefined && parsedValue > props.maxValue) {
130
- ers.push(`Value must be less than ${props.maxValue}`);
131
- }
132
129
  }
133
130
 
134
131
  ers = [...ers];
@@ -137,18 +134,22 @@ const errors = computed(() => {
137
134
  });
138
135
 
139
136
  const isIncrementDisabled = computed(() => {
140
- const parsedValue = innerNumberValue.value;
141
- if (props.maxValue !== undefined && parsedValue !== undefined) {
142
- return parsedValue >= props.maxValue;
137
+ const r = parsedResult.value;
138
+
139
+ if (props.maxValue !== undefined && r.value !== undefined) {
140
+ return r.value >= props.maxValue;
143
141
  }
142
+
144
143
  return false;
145
144
  });
146
145
 
147
146
  const isDecrementDisabled = computed(() => {
148
- const parsedValue = innerNumberValue.value;
149
- if (props.minValue !== undefined && parsedValue !== undefined) {
150
- return parsedValue <= props.minValue;
147
+ const r = parsedResult.value;
148
+
149
+ if (props.minValue !== undefined && r.value !== undefined) {
150
+ return r.value <= props.minValue;
151
151
  }
152
+
152
153
  return false;
153
154
  });
154
155
 
@@ -157,7 +158,10 @@ const multiplier = computed(() =>
157
158
  );
158
159
 
159
160
  function increment() {
160
- const parsedValue = innerNumberValue.value;
161
+ const r = parsedResult.value;
162
+
163
+ const parsedValue = r.value;
164
+
161
165
  if (!isIncrementDisabled.value) {
162
166
  let nV;
163
167
  if (parsedValue === undefined) {
@@ -172,7 +176,10 @@ function increment() {
172
176
  }
173
177
 
174
178
  function decrement() {
175
- const parsedValue = innerNumberValue.value;
179
+ const r = parsedResult.value;
180
+
181
+ const parsedValue = r.value;
182
+
176
183
  if (!isDecrementDisabled.value) {
177
184
  let nV;
178
185
  if (parsedValue === undefined) {
@@ -189,24 +196,26 @@ function decrement() {
189
196
  function handleKeyPress(e: { code: string; preventDefault(): void }) {
190
197
  if (props.updateOnEnterOrClickOutside) {
191
198
  if (e.code === 'Escape') {
192
- innerTextValue.value = modelToString(modelValue.value);
193
- input.value?.blur();
199
+ inputValue.value = modelToString(modelValue.value);
200
+ inputRef.value?.blur();
194
201
  }
195
202
  if (e.code === 'Enter') {
196
- input.value?.blur();
203
+ inputRef.value?.blur();
197
204
  }
198
205
  }
199
206
 
200
207
  if (e.code === 'Enter') {
201
- innerTextValue.value = String(modelValue.value); // to make .1 => 0.1, 10.00 => 10, remove leading zeros etc
208
+ inputValue.value = String(modelValue.value); // to make .1 => 0.1, 10.00 => 10, remove leading zeros etc
202
209
  }
203
210
 
204
211
  if (['ArrowDown', 'ArrowUp'].includes(e.code)) {
205
212
  e.preventDefault();
206
213
  }
214
+
207
215
  if (props.useIncrementButtons && e.code === 'ArrowUp') {
208
216
  increment();
209
217
  }
218
+
210
219
  if (props.useIncrementButtons && e.code === 'ArrowDown') {
211
220
  decrement();
212
221
  }
@@ -224,15 +233,15 @@ const onMousedown = (ev: MouseEvent) => {
224
233
 
225
234
  <template>
226
235
  <div
227
- ref="root"
236
+ ref="rootRef"
228
237
  :class="{ error: !!errors.trim(), disabled: disabled }"
229
- class="mi-number-field d-flex-column"
238
+ class="pl-number-field d-flex-column"
230
239
  @keydown="handleKeyPress($event)"
231
240
  >
232
- <div class="mi-number-field__main-wrapper d-flex">
233
- <DoubleContour class="mi-number-field__contour"/>
241
+ <div class="pl-number-field__main-wrapper d-flex">
242
+ <DoubleContour class="pl-number-field__contour"/>
234
243
  <div
235
- class="mi-number-field__wrapper flex-grow d-flex flex-align-center"
244
+ class="pl-number-field__wrapper flex-grow d-flex flex-align-center"
236
245
  :class="{withoutArrows: !useIncrementButtons}"
237
246
  >
238
247
  <label v-if="label" class="text-description">
@@ -244,7 +253,7 @@ const onMousedown = (ev: MouseEvent) => {
244
253
  </PlTooltip>
245
254
  </label>
246
255
  <input
247
- ref="input"
256
+ ref="inputRef"
248
257
  v-model="inputValue"
249
258
  :disabled="disabled"
250
259
  :placeholder="placeholder"
@@ -253,10 +262,10 @@ const onMousedown = (ev: MouseEvent) => {
253
262
  @focusout="focused = false; applyChanges()"
254
263
  />
255
264
  </div>
256
- <div v-if="useIncrementButtons" class="mi-number-field__icons d-flex-column" @mousedown="onMousedown">
265
+ <div v-if="useIncrementButtons" class="pl-number-field__icons d-flex-column" @mousedown="onMousedown">
257
266
  <div
258
267
  :class="{ disabled: isIncrementDisabled }"
259
- class="mi-number-field__icon d-flex flex-justify-center uc-pointer flex-grow flex-align-center"
268
+ class="pl-number-field__icon d-flex flex-justify-center uc-pointer flex-grow flex-align-center"
260
269
  @click="increment"
261
270
  >
262
271
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -270,7 +279,7 @@ const onMousedown = (ev: MouseEvent) => {
270
279
  </div>
271
280
  <div
272
281
  :class="{ disabled: isDecrementDisabled }"
273
- class="mi-number-field__icon d-flex flex-justify-center uc-pointer flex-grow flex-align-center"
282
+ class="pl-number-field__icon d-flex flex-justify-center uc-pointer flex-grow flex-align-center"
274
283
  @click="decrement"
275
284
  >
276
285
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -284,7 +293,7 @@ const onMousedown = (ev: MouseEvent) => {
284
293
  </div>
285
294
  </div>
286
295
  </div>
287
- <div v-if="errors.trim()" class="mi-number-field__hint text-description">
296
+ <div v-if="errors.trim()" class="pl-number-field__error">
288
297
  {{ errors }}
289
298
  </div>
290
299
  </div>
@@ -29,7 +29,7 @@ describe('NumberInput.vue', () => {
29
29
  step: 2,
30
30
  },
31
31
  });
32
- const incrementButton = wrapper.find('.mi-number-field__icons div:first-child');
32
+ const incrementButton = wrapper.find('.pl-number-field__icons div:first-child');
33
33
  await incrementButton.trigger('click');
34
34
  expect(wrapper.vm.modelValue).toEqual(12);
35
35
  });
@@ -41,7 +41,7 @@ describe('NumberInput.vue', () => {
41
41
  step: 1,
42
42
  },
43
43
  });
44
- const decrementButton = wrapper.find('.mi-number-field__icons div:last-child');
44
+ const decrementButton = wrapper.find('.pl-number-field__icons div:last-child');
45
45
  await decrementButton.trigger('click');
46
46
  expect(wrapper.vm.modelValue).toEqual(9);
47
47
  });
@@ -53,7 +53,7 @@ describe('NumberInput.vue', () => {
53
53
  maxValue: 10,
54
54
  },
55
55
  });
56
- const incrementButton = wrapper.find('.mi-number-field__icons div:first-child');
56
+ const incrementButton = wrapper.find('.pl-number-field__icons div:first-child');
57
57
  expect(incrementButton.classes()).toContain('disabled');
58
58
  });
59
59
 
@@ -64,7 +64,7 @@ describe('NumberInput.vue', () => {
64
64
  minValue: 1,
65
65
  },
66
66
  });
67
- const decrementButton = wrapper.find('.mi-number-field__icons div:last-child');
67
+ const decrementButton = wrapper.find('.pl-number-field__icons div:last-child');
68
68
  expect(decrementButton.classes()).toContain('disabled');
69
69
  });
70
70
 
@@ -76,8 +76,8 @@ describe('NumberInput.vue', () => {
76
76
  errorMessage: 'Custom error message',
77
77
  },
78
78
  });
79
- expect(wrapper.find('.mi-number-field__hint').text()).toContain('Custom error message');
80
- expect(wrapper.find('.mi-number-field__hint').text()).toContain('Value must be higher than 10');
79
+ expect(wrapper.find('.pl-number-field__error').text()).toContain('Custom error message');
80
+ expect(wrapper.find('.pl-number-field__error').text()).toContain('Value must be higher than 10');
81
81
  });
82
82
 
83
83
  it('validates and updates the computedValue when the user types in the input field', async () => {
@@ -89,7 +89,7 @@ describe('NumberInput.vue', () => {
89
89
  const input = wrapper.find('input');
90
90
  await input.setValue('1.1.1');
91
91
  await input.trigger('focusout');
92
- expect(wrapper.vm.modelValue).toEqual(5);
92
+ expect(wrapper.vm.modelValue).toEqual(1.1);
93
93
 
94
94
  await input.setValue('15');
95
95
  await input.trigger('focusout');
@@ -97,16 +97,46 @@ describe('NumberInput.vue', () => {
97
97
 
98
98
  await input.setValue('.');
99
99
  await input.trigger('focusout');
100
- expect(wrapper.vm.modelValue).toEqual(0);
100
+ expect(wrapper.vm.modelValue).toEqual(15);
101
+
102
+ await input.setValue('..');
103
+ await input.trigger('focusout');
104
+ expect(wrapper.vm.modelValue).toEqual(15);
105
+ expect(input.element.value).toEqual('.');
106
+
107
+ await input.setValue(',,');
108
+ await input.trigger('focusout');
109
+ expect(wrapper.vm.modelValue).toEqual(15);
110
+ expect(input.element.value).toEqual('.');
111
+
112
+ await input.setValue('-');
113
+ await input.trigger('focusout');
114
+ expect(wrapper.vm.modelValue).toEqual(15);
115
+ expect(input.element.value).toEqual('-');
116
+
117
+ await input.setValue('-a');
118
+ await input.trigger('focusout');
119
+ expect(wrapper.vm.modelValue).toEqual(15);
120
+ expect(input.element.value).toEqual('-');
121
+
122
+ await input.setValue('-1');
123
+ await input.trigger('focusout');
124
+ expect(wrapper.vm.modelValue).toEqual(-1);
125
+ expect(input.element.value).toEqual('-1');
101
126
 
102
127
  await input.setValue(',');
103
128
  await input.trigger('focusout');
104
- expect(wrapper.vm.modelValue).toEqual(0);
129
+ expect(wrapper.vm.modelValue).toEqual(-1);
105
130
 
106
131
  await input.setValue('1,1');
107
132
  await input.trigger('focusout');
108
133
  expect(wrapper.vm.modelValue).toEqual(1.1);
109
134
  expect(input.element.value).toEqual('1.1');
135
+
136
+ await input.setValue('-1.1');
137
+ await input.trigger('focusout');
138
+ expect(wrapper.vm.modelValue).toEqual(-1.1);
139
+ expect(input.element.value).toEqual('-1.1');
110
140
  });
111
141
 
112
142
  it('update model with undefined when input is cleared', async () => {
@@ -120,4 +150,31 @@ describe('NumberInput.vue', () => {
120
150
  await input.trigger('focusout');
121
151
  expect(wrapper.vm.modelValue).toEqual(undefined);
122
152
  });
153
+
154
+ it('external modelValue change', async () => {
155
+ const wrapper = mount(PlNumberField, {
156
+ props: {
157
+ modelValue: 10,
158
+ },
159
+ });
160
+
161
+ const input = wrapper.find('input');
162
+ await input.trigger('focusout');
163
+ expect(wrapper.vm.modelValue).toEqual(10);
164
+ expect(input.element.value).toEqual('10');
165
+
166
+ await input.setValue('');
167
+ await input.trigger('focusout');
168
+ expect(wrapper.vm.modelValue).toEqual(undefined);
169
+
170
+ wrapper.setProps({ modelValue: 1 });
171
+
172
+ await input.trigger('focusout');
173
+ expect(wrapper.vm.modelValue).toEqual(1);
174
+ expect(input.element.value).toEqual('1');
175
+
176
+ await input.setValue(10);
177
+ expect(wrapper.vm.modelValue).toEqual(10);
178
+ expect(input.element.value).toEqual('10');
179
+ });
123
180
  });
@@ -0,0 +1,121 @@
1
+ type ParseResult = {
2
+ error?: Error;
3
+ value?: number;
4
+ cleanInput: string;
5
+ };
6
+
7
+ const NUMBER_REGEX = /^[-−–+]?(\d+)?[.,]?(\d+)?$/; // parseFloat works without errors on strings with multiple dots, or letters in value
8
+
9
+ function isPartial(v: string) {
10
+ return v === '.' || v === ',' || v === '-';
11
+ }
12
+
13
+ function clearNumericValue(v: string) {
14
+ v = v.trim();
15
+ v = v.replace(',', '.');
16
+ v = v.replace('−', '-'); // minus, replacing for the case of input the whole copied value
17
+ v = v.replace('–', '-'); // dash, replacing for the case of input the whole copied value
18
+ v = v.replace('+', '');
19
+ return v;
20
+ }
21
+
22
+ function stringToNumber(v: string) {
23
+ return parseFloat(clearNumericValue(v));
24
+ }
25
+
26
+ function clearInput(v: string): string {
27
+ v = v.trim();
28
+
29
+ if (isPartial(v)) {
30
+ return v;
31
+ }
32
+
33
+ if (/^-[^0-9.]/.test(v)) {
34
+ return '-';
35
+ }
36
+
37
+ const match = v.match(/^(.*)[.,][^0-9].*$/);
38
+ if (match) {
39
+ return match[1] + '.';
40
+ }
41
+
42
+ if (v.match(NUMBER_REGEX)) {
43
+ return clearNumericValue(v);
44
+ }
45
+
46
+ const n = stringToNumber(v);
47
+
48
+ return isNaN(n) ? '' : String(+n);
49
+ }
50
+
51
+ export function parseNumber(props: {
52
+ minValue?: number;
53
+ maxValue?: number;
54
+ validate?: (v: number) => string | undefined;
55
+ }, str: string): ParseResult {
56
+ str = str.trim();
57
+
58
+ const cleanInput = clearInput(str);
59
+
60
+ if (str === '') {
61
+ return {
62
+ value: undefined,
63
+ cleanInput,
64
+ };
65
+ }
66
+
67
+ if (!str.match(NUMBER_REGEX)) {
68
+ return {
69
+ error: Error('Value is not a number'),
70
+ cleanInput,
71
+ };
72
+ }
73
+
74
+ if (isPartial(str)) {
75
+ return {
76
+ error: Error('Enter a number'),
77
+ cleanInput,
78
+ };
79
+ }
80
+
81
+ const value = stringToNumber(str);
82
+
83
+ if (isNaN(value)) {
84
+ return {
85
+ error: Error('Value is not a number'),
86
+ cleanInput,
87
+ };
88
+ }
89
+
90
+ if (props.minValue !== undefined && value < props.minValue) {
91
+ return {
92
+ error: Error(`Value must be higher than ${props.minValue}`),
93
+ value,
94
+ cleanInput,
95
+ };
96
+ }
97
+
98
+ if (props.maxValue !== undefined && value > props.maxValue) {
99
+ return {
100
+ error: Error(`Value must be less than ${props.maxValue}`),
101
+ value,
102
+ cleanInput,
103
+ };
104
+ }
105
+
106
+ if (props.validate) {
107
+ const error = props.validate(value);
108
+ if (error) {
109
+ return {
110
+ error: Error(error),
111
+ value,
112
+ cleanInput,
113
+ };
114
+ }
115
+ }
116
+
117
+ return {
118
+ value,
119
+ cleanInput,
120
+ };
121
+ }
@@ -1,4 +1,4 @@
1
- .mi-number-field {
1
+ .pl-number-field {
2
2
  --contour-color: var(--txt-01);
3
3
  --contour-border-width: 1px;
4
4
  --options-bg: #fff;
@@ -65,6 +65,14 @@
65
65
  color: var(--color-hint);
66
66
  }
67
67
 
68
+ &__error {
69
+ margin-top: 3px;
70
+ color: var(--txt-error);
71
+ font-size: 12px;
72
+ font-weight: 500;
73
+ line-height: 16px;
74
+ }
75
+
68
76
  input {
69
77
  outline: none;
70
78
  border: none;