@milaboratories/uikit 2.2.66 → 2.2.67

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/pl-uikit.js +5490 -5196
  3. package/dist/pl-uikit.js.map +1 -1
  4. package/dist/pl-uikit.umd.cjs +10 -10
  5. package/dist/pl-uikit.umd.cjs.map +1 -1
  6. package/dist/src/components/PlAutocomplete/PlAutocomplete.vue.d.ts +85 -0
  7. package/dist/src/components/PlAutocomplete/PlAutocomplete.vue.d.ts.map +1 -0
  8. package/dist/src/components/PlAutocomplete/__tests__/PlAutocomplete.spec.d.ts +2 -0
  9. package/dist/src/components/PlAutocomplete/__tests__/PlAutocomplete.spec.d.ts.map +1 -0
  10. package/dist/src/components/PlAutocomplete/index.d.ts +2 -0
  11. package/dist/src/components/PlAutocomplete/index.d.ts.map +1 -0
  12. package/dist/src/components/PlDropdown/PlDropdown.vue.d.ts.map +1 -1
  13. package/dist/src/components/PlDropdownMulti/PlDropdownMulti.vue.d.ts.map +1 -1
  14. package/dist/src/index.d.ts +1 -0
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/style.css +1 -1
  17. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  18. package/package.json +3 -3
  19. package/src/components/PlAutocomplete/PlAutocomplete.vue +413 -0
  20. package/src/components/PlAutocomplete/__tests__/PlAutocomplete.spec.ts +41 -0
  21. package/src/components/PlAutocomplete/index.ts +1 -0
  22. package/src/components/PlAutocomplete/pl-autocomplete.scss +277 -0
  23. package/src/components/PlDropdown/PlDropdown.vue +13 -6
  24. package/src/components/PlDropdown/pl-dropdown.scss +13 -3
  25. package/src/components/PlDropdownMulti/PlDropdownMulti.vue +14 -6
  26. package/src/components/PlDropdownMulti/pl-dropdown-multi.scss +39 -25
  27. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/uikit",
3
- "version": "2.2.66",
3
+ "version": "2.2.67",
4
4
  "type": "module",
5
5
  "main": "dist/pl-uikit.umd.js",
6
6
  "module": "dist/pl-uikit.js",
@@ -35,8 +35,8 @@
35
35
  "svgo": "^3.3.2",
36
36
  "@types/d3": "^7.4.3",
37
37
  "@milaboratories/eslint-config": "^1.0.4",
38
- "@platforma-sdk/model": "^1.28.1",
39
- "@milaboratories/helpers": "^1.6.11"
38
+ "@milaboratories/helpers": "^1.6.11",
39
+ "@platforma-sdk/model": "^1.29.2"
40
40
  },
41
41
  "scripts": {
42
42
  "dev": "vite",
@@ -0,0 +1,413 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A component for selecting one value from a big list of options using string search request
4
+ */
5
+ export default {
6
+ name: 'PlAutocomplete',
7
+ };
8
+ </script>
9
+
10
+ <script lang="ts" setup generic="M = unknown">
11
+ import './pl-autocomplete.scss';
12
+ import { computed, reactive, ref, unref, useSlots, useTemplateRef, watch, watchPostEffect } from 'vue';
13
+ import { tap } from '@/helpers/functions';
14
+ import { PlTooltip } from '@/components/PlTooltip';
15
+ import DoubleContour from '@/utils/DoubleContour.vue';
16
+ import { useLabelNotch } from '@/utils/useLabelNotch';
17
+ import type { ListOption, ListOptionNormalized } from '@/types';
18
+ import { deepEqual } from '@/helpers/objects';
19
+ import DropdownListItem from '@/components/DropdownListItem.vue';
20
+ import LongText from '@/components/LongText.vue';
21
+ import { normalizeListOptions } from '@/helpers/utils';
22
+ import { PlIcon16 } from '../PlIcon16';
23
+ import { PlMaskIcon24 } from '../PlMaskIcon24';
24
+ import { DropdownOverlay } from '@/utils/DropdownOverlay';
25
+ import { refDebounced } from '@vueuse/core';
26
+ import { useWatchFetch } from '@/composition/useWatchFetch.ts';
27
+
28
+ /**
29
+ * The current selected value.
30
+ */
31
+ const model = defineModel<M>({ required: true });
32
+
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ /**
36
+ * Lambda for requesting of available options for the dropdown by search string.
37
+ */
38
+ optionsSearch: (s: string) => Promise<ListOption<M>[]>;
39
+ /**
40
+ * Lambda for requesting of corresponding option for current model value. If empty, optionsSearch is used for this.
41
+ */
42
+ modelSearch?: (v: M) => Promise<ListOption<M>>;
43
+ /**
44
+ * The label text for the dropdown field (optional)
45
+ */
46
+ label?: string;
47
+ /**
48
+ * A helper text displayed below the dropdown when there are no errors (optional).
49
+ */
50
+ helper?: string;
51
+ /**
52
+ * A helper text displayed below the dropdown when there are no options yet or options is undefined (optional).
53
+ */
54
+ loadingOptionsHelper?: string;
55
+ /**
56
+ * Error message displayed below the dropdown (optional)
57
+ */
58
+ error?: string;
59
+ /**
60
+ * Placeholder text shown when no value is selected.
61
+ */
62
+ placeholder?: string;
63
+ /**
64
+ * Enables a button to clear the selected value (default: false)
65
+ */
66
+ clearable?: boolean;
67
+ /**
68
+ * If `true`, the dropdown component is marked as required.
69
+ */
70
+ required?: boolean;
71
+ /**
72
+ * If `true`, the dropdown component is disabled and cannot be interacted with.
73
+ */
74
+ disabled?: boolean;
75
+ /**
76
+ * Custom icon (16px) class for the dropdown arrow (optional)
77
+ */
78
+ arrowIcon?: string;
79
+ /**
80
+ * Custom icon (24px) class for the dropdown arrow (optional)
81
+ */
82
+ arrowIconLarge?: string;
83
+ /**
84
+ * Option list item size
85
+ */
86
+ optionSize?: 'small' | 'medium';
87
+ /**
88
+ * Formatter for the selected value if its label is absent
89
+ */
90
+ formatValue?: (value: M) => string;
91
+ }>(),
92
+ {
93
+ modelSearch: undefined,
94
+ label: '',
95
+ helper: undefined,
96
+ loadingOptionsHelper: undefined,
97
+ error: undefined,
98
+ placeholder: '...',
99
+ clearable: false,
100
+ required: false,
101
+ disabled: false,
102
+ arrowIcon: undefined,
103
+ arrowIconLarge: undefined,
104
+ optionSize: 'small',
105
+ formatValue: (v: M) => String(v),
106
+ },
107
+ );
108
+
109
+ const slots = useSlots();
110
+
111
+ const rootRef = ref<HTMLElement | undefined>();
112
+ const input = ref<HTMLInputElement | undefined>();
113
+
114
+ const overlayRef = useTemplateRef('overlay');
115
+
116
+ const search = ref<string | null>(null);
117
+ const data = reactive({
118
+ activeIndex: -1,
119
+ open: false,
120
+ });
121
+
122
+ const findActiveIndex = () =>
123
+ tap(
124
+ renderedOptionsRef.value.findIndex((o) => deepEqual(o.value, model.value)),
125
+ (v) => (v < 0 ? 0 : v),
126
+ );
127
+
128
+ const updateActive = () => (data.activeIndex = findActiveIndex());
129
+
130
+ const loadedOptionsRef = ref<ListOption<M>[]>([]);
131
+ const modelOptionRef = ref<ListOptionNormalized<M> | undefined>(); // list of 1 option that is selected or empty, to keep selected label
132
+
133
+ const renderedOptionsRef = computed(() => {
134
+ if (model.value && !search.value) {
135
+ return modelOptionRef.value
136
+ ? [{
137
+ ...modelOptionRef.value,
138
+ index: 0,
139
+ isSelected: true,
140
+ isActive: true,
141
+ }]
142
+ : [];
143
+ }
144
+ return normalizeListOptions(loadedOptionsRef.value).map((opt, index) => ({
145
+ ...opt,
146
+ index,
147
+ isSelected: index === selectedIndex.value,
148
+ isActive: index === data.activeIndex,
149
+ }));
150
+ });
151
+ const isLoadingOptions = ref<boolean>(true);
152
+ const isLoadingError = ref<boolean>(false);
153
+
154
+ const isDisabled = computed(() => {
155
+ return props.disabled;
156
+ });
157
+
158
+ const selectedIndex = computed(() => {
159
+ return loadedOptionsRef.value.findIndex((o) => deepEqual(o.value, model.value));
160
+ });
161
+
162
+ const computedError = computed(() => {
163
+ if (isLoadingOptions.value) {
164
+ return undefined;
165
+ }
166
+
167
+ if (props.error) {
168
+ return props.error;
169
+ }
170
+
171
+ if (isLoadingError.value) {
172
+ return 'Data loading error';
173
+ }
174
+
175
+ return undefined;
176
+ });
177
+
178
+ const textValue = computed(() => {
179
+ const modelOption = unref(modelOptionRef);
180
+ const options = unref(renderedOptionsRef);
181
+
182
+ const item: ListOptionNormalized | undefined = modelOption ?? options.find((o) => deepEqual(o.value, model.value)) ?? options.find((o) => deepEqual(o.value, model.value));
183
+
184
+ return item?.label || (model.value ? props.formatValue(model.value) : '');
185
+ });
186
+
187
+ const computedPlaceholder = computed(() => {
188
+ if (!data.open && model.value) {
189
+ return '';
190
+ }
191
+
192
+ return model.value ? String(textValue.value) : props.placeholder;
193
+ });
194
+
195
+ const hasValue = computed(() => {
196
+ return model.value !== undefined && model.value !== null;
197
+ });
198
+
199
+ const tabindex = computed(() => (isDisabled.value ? undefined : '0'));
200
+
201
+ const selectOption = (v: ListOptionNormalized<M> | undefined) => {
202
+ model.value = v?.value as M;
203
+ modelOptionRef.value = v;
204
+ search.value = null;
205
+ data.open = false;
206
+ rootRef?.value?.focus();
207
+ };
208
+
209
+ const clear = () => {
210
+ model.value = undefined as M;
211
+ modelOptionRef.value = undefined;
212
+ };
213
+
214
+ const setFocusOnInput = () => input.value?.focus();
215
+
216
+ const toggleOpen = () => {
217
+ data.open = !data.open;
218
+ if (!data.open) {
219
+ search.value = null;
220
+ }
221
+ if (data.open) {
222
+ search.value = '';
223
+ }
224
+ };
225
+
226
+ const onInputFocus = () => {
227
+ data.open = true;
228
+ };
229
+
230
+ const onFocusOut = (event: FocusEvent) => {
231
+ const relatedTarget = event.relatedTarget as Node | null;
232
+
233
+ if (!rootRef.value?.contains(relatedTarget) && !overlayRef.value?.listRef?.contains(relatedTarget)) {
234
+ search.value = null;
235
+ data.open = false;
236
+ }
237
+ };
238
+
239
+ const handleKeydown = (e: { code: string; preventDefault(): void }) => {
240
+ if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.code)) {
241
+ return;
242
+ } else {
243
+ e.preventDefault();
244
+ }
245
+
246
+ const { open, activeIndex } = data;
247
+
248
+ if (!open) {
249
+ if (e.code === 'Enter') {
250
+ data.open = true;
251
+ search.value = '';
252
+ }
253
+ return;
254
+ }
255
+
256
+ if (e.code === 'Escape') {
257
+ data.open = false;
258
+ search.value = null;
259
+ rootRef.value?.focus();
260
+ }
261
+
262
+ const options = unref(renderedOptionsRef);
263
+
264
+ const { length } = options;
265
+
266
+ if (!length) {
267
+ return;
268
+ }
269
+
270
+ if (e.code === 'Enter') {
271
+ selectOption(options.find((it) => it.index === activeIndex));
272
+ }
273
+
274
+ const localIndex = options.findIndex((it) => it.index === activeIndex) ?? -1;
275
+
276
+ const delta = e.code === 'ArrowDown' ? 1 : e.code === 'ArrowUp' ? -1 : 0;
277
+
278
+ const newIndex = Math.abs(localIndex + delta + length) % length;
279
+
280
+ data.activeIndex = renderedOptionsRef.value[newIndex].index ?? -1;
281
+ };
282
+
283
+ useLabelNotch(rootRef);
284
+
285
+ watch(() => model.value, updateActive, { immediate: true });
286
+
287
+ watch(
288
+ () => data.open,
289
+ (open) => (open ? input.value?.focus() : ''),
290
+ );
291
+
292
+ watchPostEffect(() => {
293
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
294
+ search.value; // to watch
295
+
296
+ if (data.activeIndex >= 0 && data.open) {
297
+ overlayRef.value?.scrollIntoActive();
298
+ }
299
+ });
300
+
301
+ const searchDebounced = refDebounced(search, 500, { maxWait: 1000 });
302
+
303
+ const optionsRequest = useWatchFetch(() => searchDebounced.value, async (v) => {
304
+ if (v !== null && !(v === '' && model.value)) { // search is null when dropdown is closed; when search is '' and model is not empty show single selected option in the list;
305
+ return props.optionsSearch(v);
306
+ }
307
+ return [];
308
+ });
309
+
310
+ const modelOptionRequest = useWatchFetch(() => model.value, async (v) => {
311
+ if (v && !deepEqual(modelOptionRef.value?.value, v)) { // load label for selected value if it was updated from outside the component
312
+ if (props.modelSearch) {
313
+ return props.modelSearch(v);
314
+ }
315
+ return (await props.optionsSearch(String(v)))?.[0];
316
+ }
317
+ return modelOptionRef.value;
318
+ });
319
+
320
+ watch(() => optionsRequest.value, (result) => {
321
+ if (result) {
322
+ loadedOptionsRef.value = result;
323
+ if (search.value !== null) {
324
+ isLoadingError.value = false;
325
+ }
326
+ }
327
+ });
328
+
329
+ watch(() => modelOptionRequest.value, (result) => {
330
+ if (result) {
331
+ modelOptionRef.value = normalizeListOptions([result])[0];
332
+ }
333
+ });
334
+
335
+ watch(() => optionsRequest.error, (err) => {
336
+ if (err) {
337
+ isLoadingError.value = Boolean(err);
338
+ }
339
+ });
340
+
341
+ watch(() => optionsRequest.loading || modelOptionRequest.loading, (loading) => {
342
+ isLoadingOptions.value = loading;
343
+ });
344
+ </script>
345
+
346
+ <template>
347
+ <div class="pl-autocomplete__envelope" @click.stop="setFocusOnInput">
348
+ <div
349
+ ref="rootRef"
350
+ :tabindex="tabindex"
351
+ class="pl-autocomplete"
352
+ :class="{ open: data.open, error: Boolean(computedError), disabled: isDisabled }"
353
+ @keydown="handleKeydown"
354
+ @focusout="onFocusOut"
355
+ >
356
+ <div class="pl-autocomplete__container">
357
+ <div class="pl-autocomplete__field">
358
+ <input
359
+ ref="input"
360
+ v-model="search"
361
+ type="text"
362
+ tabindex="-1"
363
+ :disabled="isDisabled"
364
+ :placeholder="computedPlaceholder"
365
+ spellcheck="false"
366
+ autocomplete="chrome-off"
367
+ @focus="onInputFocus"
368
+ />
369
+
370
+ <div v-if="!data.open" class="input-value">
371
+ <LongText> {{ textValue }} </LongText>
372
+ </div>
373
+
374
+ <div class="pl-autocomplete__controls">
375
+ <PlMaskIcon24 v-if="isLoadingOptions" name="loading" />
376
+ <PlIcon16 v-if="clearable && hasValue" name="delete-clear" @click.stop="clear" />
377
+ <slot name="append" />
378
+ <div class="pl-autocomplete__arrow-wrapper" @click.stop="toggleOpen">
379
+ <div v-if="arrowIconLarge" class="arrow-icon" :class="[`icon-24 ${arrowIconLarge}`]" />
380
+ <div v-else-if="arrowIcon" class="arrow-icon" :class="[`icon-16 ${arrowIcon}`]" />
381
+ <div v-else class="arrow-icon arrow-icon-default" />
382
+ </div>
383
+ </div>
384
+ </div>
385
+ <label v-if="label">
386
+ <i v-if="required" class="required-icon" />
387
+ <span>{{ label }}</span>
388
+ <PlTooltip v-if="slots.tooltip" class="info" position="top">
389
+ <template #tooltip>
390
+ <slot name="tooltip" />
391
+ </template>
392
+ </PlTooltip>
393
+ </label>
394
+ <DropdownOverlay v-if="data.open" ref="overlay" :root="rootRef" class="pl-autocomplete__options" tabindex="-1" :gap="3">
395
+ <DropdownListItem
396
+ v-for="(item, index) in renderedOptionsRef"
397
+ :key="index"
398
+ :option="item"
399
+ :is-selected="item.isSelected"
400
+ :is-hovered="item.isActive"
401
+ :size="optionSize"
402
+ @click.stop="selectOption(item)"
403
+ />
404
+ <div v-if="!renderedOptionsRef.length" class="nothing-found">Nothing found</div>
405
+ </DropdownOverlay>
406
+ <DoubleContour class="pl-autocomplete__contour" />
407
+ </div>
408
+ </div>
409
+ <div v-if="computedError" class="pl-autocomplete__error">{{ computedError }}</div>
410
+ <div v-else-if="isLoadingOptions && loadingOptionsHelper" class="pl-autocomplete__helper">{{ loadingOptionsHelper }}</div>
411
+ <div v-else-if="helper" class="pl-autocomplete__helper">{{ helper }}</div>
412
+ </div>
413
+ </template>
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { mount } from '@vue/test-utils';
4
+ import PlAutocomplete from '../PlAutocomplete.vue';
5
+ import { delay } from '@milaboratories/helpers';
6
+
7
+ describe('PlAutocomplete', () => {
8
+ it('modelValue', async () => {
9
+ const options = [
10
+ { text: 'Option 1', value: 1 },
11
+ { text: 'Option 2', value: 2 },
12
+ ];
13
+ const wrapper = mount(PlAutocomplete, {
14
+ props: {
15
+ 'modelValue': 1,
16
+ 'onUpdate:modelValue': (e: unknown) => wrapper.setProps({ modelValue: e }),
17
+ 'optionsSearch': (_str: string) => {
18
+ return Promise.resolve(options);
19
+ },
20
+ },
21
+ });
22
+
23
+ await delay(10);
24
+ await wrapper.find('.pl-autocomplete__envelope').trigger('click');
25
+ await wrapper.find('input').trigger('focus');
26
+ await wrapper.find('input').setValue('option');
27
+ await delay(600);
28
+
29
+ const optionsRendered = [...document.body.querySelectorAll('.dropdown-list-item')] as HTMLElement[];
30
+
31
+ expect(optionsRendered.length).toBe(2);
32
+
33
+ optionsRendered[1].click();
34
+
35
+ await delay(20);
36
+
37
+ expect(wrapper.props('modelValue')).toBe(2);
38
+
39
+ expect(await wrapper.findAll('.dropdown-list-item').length).toBe(0); // options are closed after click
40
+ });
41
+ });
@@ -0,0 +1 @@
1
+ export { default as PlAutocomplete } from './PlAutocomplete.vue';