@milaboratories/uikit 2.2.53 → 2.2.54

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/uikit",
3
- "version": "2.2.53",
3
+ "version": "2.2.54",
4
4
  "type": "module",
5
5
  "main": "dist/pl-uikit.umd.js",
6
6
  "module": "dist/pl-uikit.js",
@@ -36,7 +36,7 @@
36
36
  "@types/d3": "^7.4.3",
37
37
  "@milaboratories/eslint-config": "^1.0.1",
38
38
  "@milaboratories/helpers": "^1.6.11",
39
- "@platforma-sdk/model": "^1.22.2"
39
+ "@platforma-sdk/model": "^1.22.18"
40
40
  },
41
41
  "scripts": {
42
42
  "dev": "vite",
@@ -2,18 +2,29 @@
2
2
  import './pl-number-field.scss';
3
3
  import DoubleContour from '@/utils/DoubleContour.vue';
4
4
  import { useLabelNotch } from '@/utils/useLabelNotch';
5
- import { computed, nextTick, ref, useSlots } from 'vue';
5
+ import { computed, ref, useSlots, watch } from 'vue';
6
6
  import { PlTooltip } from '@/components/PlTooltip';
7
7
 
8
8
  type NumberInputProps = {
9
- modelValue: number | undefined;
9
+ /** Input is disabled if true */
10
10
  disabled?: boolean;
11
+ /** Label on the top border of the field, empty by default */
11
12
  label?: string;
13
+ /** Input placeholder, empty by default */
12
14
  placeholder?: string;
15
+ /** Step for increment/decrement buttons, 1 by default */
13
16
  step?: number;
17
+ /** If defined - show an error if value is lower */
14
18
  minValue?: number;
19
+ /** If defined - show an error if value is higher */
15
20
  maxValue?: number;
21
+ /** If false - remove buttons on the right */
22
+ useIncrementButtons?: boolean;
23
+ /** If true - changes do not apply immediately, they apply only by removing focus from the input (by click enter or by click outside) */
24
+ updateOnEnterOrClickOutside?: boolean;
25
+ /** Error message that shows always when it's provided, without other checks */
16
26
  errorMessage?: string;
27
+ /** Additional validity check for input value that must return an error text if failed */
17
28
  validate?: (v: number) => string | undefined;
18
29
  };
19
30
 
@@ -23,68 +34,100 @@ const props = withDefaults(defineProps<NumberInputProps>(), {
23
34
  placeholder: undefined,
24
35
  minValue: undefined,
25
36
  maxValue: undefined,
37
+ useIncrementButtons: true,
38
+ updateOnEnter: false,
26
39
  errorMessage: undefined,
27
40
  validate: undefined,
28
41
  });
29
42
 
30
- const emit = defineEmits<{ (e: 'update:modelValue', number?: number): void }>();
43
+ const modelValue = defineModel<number | undefined>({ required: true });
31
44
 
32
45
  const root = ref<HTMLElement>();
33
46
  const slots = useSlots();
34
47
  const input = ref<HTMLInputElement>();
35
- // const localErrors = ref<string[]>([]);
36
48
 
37
49
  useLabelNotch(root);
38
50
 
39
- const canRenderValue = ref(true);
51
+ function modelToString(v: number | undefined) {
52
+ return v === undefined ? '' : String(+v); // (+v) to avoid staying in input non-number values if they are provided in model
53
+ }
54
+
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
+ }
72
+
73
+ const innerTextValue = ref(modelToString(modelValue.value));
74
+ const innerNumberValue = computed(() => stringToModel(innerTextValue.value));
75
+
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
+ }
80
+ });
40
81
 
41
- const computedValue = computed({
82
+ const NUMBER_REGEX = /^[-−–+]?(\d+)?[\\.,]?(\d+)?$/; // parseFloat works without errors on strings with multiple dots, or letters in value
83
+ const inputValue = computed({
42
84
  get() {
43
- if (canRenderValue.value) {
44
- if (props.modelValue !== undefined) {
45
- const num = new Number(props.modelValue);
46
- return num.toString();
47
- }
48
- return '';
49
- }
50
- return '';
85
+ return innerTextValue.value;
51
86
  },
52
- set(val) {
53
- val = val.replace(/,/g, '');
54
- if (isNumeric(val)) {
55
- emit('update:modelValue', +val);
56
- // try press 123.12345678912345 and than 6
57
- if (val.toString() !== props.modelValue?.toString() && +val === props.modelValue && val[val.length - 1] !== '.') {
58
- canRenderValue.value = false;
59
- nextTick(() => {
60
- canRenderValue.value = true;
61
- });
87
+ 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();
62
96
  }
63
- } else {
64
- if (val.trim() === '') {
65
- emit('update:modelValue', undefined);
66
- }
67
- canRenderValue.value = false;
68
- nextTick(() => {
69
- canRenderValue.value = true;
70
- });
97
+ } else if (input.value) {
98
+ input.value.value = innerTextValue.value;
71
99
  }
72
100
  },
73
101
  });
102
+ const focused = ref(false);
103
+
104
+ function applyChanges() {
105
+ if (innerTextValue.value === '') {
106
+ modelValue.value = undefined;
107
+ return;
108
+ }
109
+ modelValue.value = innerNumberValue.value;
110
+ }
74
111
 
75
112
  const errors = computed(() => {
76
113
  let ers: string[] = [];
77
114
  if (props.errorMessage) {
78
115
  ers.push(props.errorMessage);
79
116
  }
80
- if (!isNumeric(props.modelValue)) {
81
- ers.push('Model value is not a number.');
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);
122
+ if (error) {
123
+ ers.push(error);
124
+ }
82
125
  } else {
83
- if (props.minValue !== undefined && props.modelValue !== undefined && props.modelValue < props.minValue) {
84
- ers.push(`Model value must be higher than ${props.minValue}`);
126
+ if (props.minValue !== undefined && parsedValue !== undefined && parsedValue < props.minValue) {
127
+ ers.push(`Value must be higher than ${props.minValue}`);
85
128
  }
86
- if (props.maxValue !== undefined && props.modelValue !== undefined && props.modelValue > props.maxValue) {
87
- ers.push(`Model value must be less than ${props.maxValue}`);
129
+ if (props.maxValue !== undefined && parsedValue !== undefined && parsedValue > props.maxValue) {
130
+ ers.push(`Value must be less than ${props.maxValue}`);
88
131
  }
89
132
  }
90
133
 
@@ -94,69 +137,76 @@ const errors = computed(() => {
94
137
  });
95
138
 
96
139
  const isIncrementDisabled = computed(() => {
97
- if (props.maxValue && props.modelValue !== undefined) {
98
- if ((props.modelValue || 0) + props.step > props.maxValue) {
99
- return true;
100
- }
140
+ const parsedValue = innerNumberValue.value;
141
+ if (props.maxValue !== undefined && parsedValue !== undefined) {
142
+ return parsedValue >= props.maxValue;
101
143
  }
102
-
103
144
  return false;
104
145
  });
105
146
 
106
147
  const isDecrementDisabled = computed(() => {
107
- if (props.minValue && props.modelValue !== undefined) {
108
- if ((props.modelValue || 0) - props.step < props.minValue) {
109
- return true;
110
- }
148
+ const parsedValue = innerNumberValue.value;
149
+ if (props.minValue !== undefined && parsedValue !== undefined) {
150
+ return parsedValue <= props.minValue;
111
151
  }
112
-
113
152
  return false;
114
153
  });
115
154
 
116
- function isNumeric(str: string | number | undefined) {
117
- if (str !== undefined) {
118
- str = str?.toString();
119
- return !isNaN(+str) && !isNaN(parseFloat(str));
120
- }
121
- return false;
122
- }
123
-
124
155
  function increment() {
156
+ const parsedValue = innerNumberValue.value;
125
157
  if (!isIncrementDisabled.value) {
126
- let nV = 0;
127
- if (props.modelValue === undefined) {
158
+ let nV;
159
+ if (parsedValue === undefined) {
128
160
  nV = props.minValue ? props.minValue : 0;
129
161
  } else {
130
- nV = +(props.modelValue || 0) + props.step;
162
+ nV = (parsedValue || 0) + props.step;
131
163
  }
132
-
133
- computedValue.value = nV.toString();
164
+ modelValue.value = props.maxValue !== undefined ? Math.min(props.maxValue, nV) : nV;
134
165
  }
135
166
  }
136
167
 
137
168
  function decrement() {
169
+ const parsedValue = innerNumberValue.value;
138
170
  if (!isDecrementDisabled.value) {
139
- let nV = 0;
140
- if (props.modelValue === undefined) {
171
+ let nV;
172
+ if (parsedValue === undefined) {
141
173
  nV = 0;
142
174
  } else {
143
- nV = +(props.modelValue || 0) - props.step;
175
+ nV = +(parsedValue || 0) - props.step;
144
176
  }
145
-
146
- computedValue.value = props.minValue ? Math.max(props.minValue, nV).toString() : nV.toString();
177
+ modelValue.value = props.minValue !== undefined ? Math.max(props.minValue, nV) : nV;
147
178
  }
148
179
  }
149
180
 
150
181
  function handleKeyPress(e: { code: string; preventDefault(): void }) {
182
+ if (props.updateOnEnterOrClickOutside) {
183
+ if (e.code === 'Escape') {
184
+ innerTextValue.value = modelToString(modelValue.value);
185
+ input.value?.blur();
186
+ }
187
+ if (e.code === 'Enter') {
188
+ input.value?.blur();
189
+ }
190
+ }
191
+
192
+ if (e.code === 'Enter') {
193
+ innerTextValue.value = String(modelValue.value); // to make .1 => 0.1, 10.00 => 10, remove leading zeros etc
194
+ }
195
+
151
196
  if (['ArrowDown', 'ArrowUp'].includes(e.code)) {
152
197
  e.preventDefault();
153
198
  }
154
-
155
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
156
- e.code === 'ArrowUp' ? increment() : e.code === 'ArrowDown' ? decrement() : undefined;
199
+ if (props.useIncrementButtons && e.code === 'ArrowUp') {
200
+ increment();
201
+ }
202
+ if (props.useIncrementButtons && e.code === 'ArrowDown') {
203
+ decrement();
204
+ }
157
205
  }
158
206
 
159
207
  // https://stackoverflow.com/questions/880512/prevent-text-selection-after-double-click#:~:text=If%20you%20encounter%20a%20situation,none%3B%20to%20the%20summary%20element.
208
+ // this prevents selecting of more than input content in some cases,
209
+ // but also disable selecting input content by double-click (useful feature)
160
210
  const onMousedown = (ev: MouseEvent) => {
161
211
  if (ev.detail > 1) {
162
212
  ev.preventDefault();
@@ -169,23 +219,33 @@ const onMousedown = (ev: MouseEvent) => {
169
219
  ref="root"
170
220
  :class="{ error: !!errors.trim(), disabled: disabled }"
171
221
  class="mi-number-field d-flex-column"
172
- @mousedown="onMousedown"
173
222
  @keydown="handleKeyPress($event)"
174
223
  >
175
224
  <div class="mi-number-field__main-wrapper d-flex">
176
- <DoubleContour class="mi-number-field__contour" />
177
- <div class="mi-number-field__wrapper flex-grow d-flex flex-align-center">
225
+ <DoubleContour class="mi-number-field__contour"/>
226
+ <div
227
+ class="mi-number-field__wrapper flex-grow d-flex flex-align-center"
228
+ :class="{withoutArrows: !useIncrementButtons}"
229
+ >
178
230
  <label v-if="label" class="text-description">
179
231
  {{ label }}
180
232
  <PlTooltip v-if="slots.tooltip" class="info" position="top">
181
233
  <template #tooltip>
182
- <slot name="tooltip" />
234
+ <slot name="tooltip"/>
183
235
  </template>
184
236
  </PlTooltip>
185
237
  </label>
186
- <input ref="input" v-model="computedValue" :disabled="disabled" :placeholder="placeholder" class="text-s flex-grow" />
238
+ <input
239
+ ref="input"
240
+ v-model="inputValue"
241
+ :disabled="disabled"
242
+ :placeholder="placeholder"
243
+ class="text-s flex-grow"
244
+ @focusin="focused = true"
245
+ @focusout="focused = false; applyChanges()"
246
+ />
187
247
  </div>
188
- <div class="mi-number-field__icons d-flex-column">
248
+ <div v-if="useIncrementButtons" class="mi-number-field__icons d-flex-column" @mousedown="onMousedown">
189
249
  <div
190
250
  :class="{ disabled: isIncrementDisabled }"
191
251
  class="mi-number-field__icon d-flex flex-justify-center uc-pointer flex-grow flex-align-center"
@@ -31,7 +31,7 @@ describe('NumberInput.vue', () => {
31
31
  });
32
32
  const incrementButton = wrapper.find('.mi-number-field__icons div:first-child');
33
33
  await incrementButton.trigger('click');
34
- expect(wrapper.emitted('update:modelValue')).toEqual([[12]]);
34
+ expect(wrapper.vm.modelValue).toEqual(12);
35
35
  });
36
36
 
37
37
  it('decrements the value when decrement button is clicked', async () => {
@@ -43,7 +43,7 @@ describe('NumberInput.vue', () => {
43
43
  });
44
44
  const decrementButton = wrapper.find('.mi-number-field__icons div:last-child');
45
45
  await decrementButton.trigger('click');
46
- expect(wrapper.emitted('update:modelValue')).toEqual([[9]]);
46
+ expect(wrapper.vm.modelValue).toEqual(9);
47
47
  });
48
48
 
49
49
  it('disables increment button when value exceeds maxValue', () => {
@@ -77,7 +77,7 @@ describe('NumberInput.vue', () => {
77
77
  },
78
78
  });
79
79
  expect(wrapper.find('.mi-number-field__hint').text()).toContain('Custom error message');
80
- expect(wrapper.find('.mi-number-field__hint').text()).toContain('Model value must be higher than 10');
80
+ expect(wrapper.find('.mi-number-field__hint').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 () => {
@@ -87,11 +87,29 @@ describe('NumberInput.vue', () => {
87
87
  },
88
88
  });
89
89
  const input = wrapper.find('input');
90
+ await input.setValue('1.1.1');
91
+ await input.trigger('focusout');
92
+ expect(wrapper.vm.modelValue).toEqual(5);
93
+
90
94
  await input.setValue('15');
91
- expect(wrapper.emitted('update:modelValue')).toEqual([[15]]);
95
+ await input.trigger('focusout');
96
+ expect(wrapper.vm.modelValue).toEqual(15);
97
+
98
+ await input.setValue('.');
99
+ await input.trigger('focusout');
100
+ expect(wrapper.vm.modelValue).toEqual(0);
101
+
102
+ await input.setValue(',');
103
+ await input.trigger('focusout');
104
+ expect(wrapper.vm.modelValue).toEqual(0);
105
+
106
+ await input.setValue('1,1');
107
+ await input.trigger('focusout');
108
+ expect(wrapper.vm.modelValue).toEqual(1.1);
109
+ expect(input.element.value).toEqual('1.1');
92
110
  });
93
111
 
94
- it('emits update:modelValue with undefined when input is cleared', async () => {
112
+ it('update model with undefined when input is cleared', async () => {
95
113
  const wrapper = mount(PlNumberField, {
96
114
  props: {
97
115
  modelValue: 10,
@@ -99,6 +117,7 @@ describe('NumberInput.vue', () => {
99
117
  });
100
118
  const input = wrapper.find('input');
101
119
  await input.setValue('');
102
- expect(wrapper.emitted('update:modelValue')).toEqual([[undefined]]);
120
+ await input.trigger('focusout');
121
+ expect(wrapper.vm.modelValue).toEqual(undefined);
103
122
  });
104
123
  });
@@ -25,6 +25,9 @@
25
25
  // background-color: rgb(111, 94, 94);
26
26
  border-radius: 6px;
27
27
  }
28
+ &__wrapper.withoutArrows {
29
+ padding-right: 12px;
30
+ }
28
31
 
29
32
  &__icons {
30
33
  // background-color: green;
@@ -67,6 +70,7 @@
67
70
  border: none;
68
71
  width: 100%;
69
72
  background: unset;
73
+ text-overflow: ellipsis;
70
74
  }
71
75
 
72
76
  &__contour {