@v1nt1248/3nclient-lib 0.1.76 → 0.1.78

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.
@@ -3662,7 +3662,7 @@ const Wh = ["id"], Oh = /* @__PURE__ */ ZI({
3662
3662
  ], 2)) : JA("", !0);
3663
3663
  };
3664
3664
  }
3665
- }), mh = "_overlay_mv0s5_1", Xh = "_dialog_mv0s5_11", jh = "_draggable_mv0s5_30", vh = "_title_mv0s5_34", Ph = "_icon_mv0s5_58", zh = "_dragHandleIcon_mv0s5_63", _h = "_closeBtn_mv0s5_69", $h = "_content_mv0s5_76", AU = "_actions_mv0s5_81", IU = "_bothBtns_mv0s5_89", QU = {
3665
+ }), mh = "_overlay_zorcd_1", Xh = "_dialog_zorcd_11", jh = "_draggable_zorcd_30", vh = "_title_zorcd_34", Ph = "_icon_zorcd_58", zh = "_dragHandleIcon_zorcd_62", _h = "_closeBtn_zorcd_68", $h = "_content_zorcd_75", AU = "_actions_zorcd_80", IU = "_bothBtns_zorcd_88", QU = {
3666
3666
  overlay: mh,
3667
3667
  dialog: Xh,
3668
3668
  draggable: jh,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@v1nt1248/3nclient-lib",
3
3
  "license": "AGPL-3.0-or-later",
4
4
  "author": "v1nt1248",
5
- "version": "0.1.76",
5
+ "version": "0.1.78",
6
6
  "description": "Library for 3NWeb clients",
7
7
  "type": "module",
8
8
  "files": [
@@ -83,6 +83,13 @@ import type {
83
83
  } from './ui3n-radio-group/types';
84
84
  import Ui3nEditable from './ui3n-editable/ui3n-editable.vue';
85
85
  import type { Ui3nEditableProps, Ui3nEditableEmits } from './ui3n-editable/types';
86
+ import Ui3nAutocomplete from './ui3n-autocomplete/ui3n-autocomplete.vue';
87
+ import type {
88
+ Ui3nAutocompleteProps,
89
+ Ui3nAutocompleteEmits,
90
+ Ui3nAutocompleteSlots,
91
+ Ui3nAutocompleteOptionBase,
92
+ } from './ui3n-autocomplete/types';
86
93
 
87
94
  export {
88
95
  Ui3nIcon,
@@ -188,4 +195,9 @@ export {
188
195
  Ui3nEditable,
189
196
  Ui3nEditableProps,
190
197
  Ui3nEditableEmits,
198
+ Ui3nAutocomplete,
199
+ Ui3nAutocompleteProps,
200
+ Ui3nAutocompleteEmits,
201
+ Ui3nAutocompleteSlots,
202
+ Ui3nAutocompleteOptionBase,
191
203
  };
@@ -0,0 +1,40 @@
1
+ import { VNode } from 'vue';
2
+
3
+ export interface Ui3nAutocompleteOptionBase {
4
+ id: string;
5
+ name?: string;
6
+ }
7
+
8
+ export type Ui3nAutocompleteValue<T extends Ui3nAutocompleteOptionBase> = {
9
+ [Property in keyof T]: T[Property];
10
+ };
11
+
12
+ export interface Ui3nAutocompleteProps<T extends Ui3nAutocompleteOptionBase> {
13
+ chips?: boolean;
14
+ clearOnSelect?: boolean;
15
+ customFilter?: (value: T, query: string) => boolean;
16
+ disabled?: boolean;
17
+ filterKeys?: string[];
18
+ hideSelected?: boolean;
19
+ items: T[];
20
+ itemTitle?: keyof T;
21
+ itemValue?: keyof T;
22
+ modelValue: T[] | Array<T[keyof T]>;
23
+ multiple?: boolean;
24
+ noDataText?: string;
25
+ placeholder?: string;
26
+ returnObject?: boolean;
27
+ }
28
+
29
+ export interface Ui3nAutocompleteEmits<T extends Ui3nAutocompleteOptionBase> {
30
+ (event: 'update:modelValue', value: T[] | Array<T[keyof T]>): void;
31
+ (event: 'update:search', value: string): void;
32
+ (event: 'update:focused', value: boolean): void;
33
+ }
34
+
35
+ export interface Ui3nAutocompleteSlots<T extends Ui3nAutocompleteOptionBase> {
36
+ item: (props: { item: T; index: number; query?: string }) => VNode;
37
+ chip: (props: { item: T | T[keyof T]; index: number }) => VNode;
38
+ noDataText: () => VNode;
39
+ selection: (props: { value: T[] | Array<T[keyof T]> }) => VNode;
40
+ }
@@ -0,0 +1,390 @@
1
+ <!--
2
+ Copyright (C) 2025 3NSoft Inc.
3
+
4
+ This program is free software: you can redistribute it and/or modify it under
5
+ the terms of the GNU General Public License as published by the Free Software
6
+ Foundation, either version 3 of the License, or (at your option) any later
7
+ version.
8
+
9
+ This program is distributed in the hope that it will be useful, but
10
+ WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12
+ See the GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License along with
15
+ this program. If not, see <http://www.gnu.org/licenses/>.
16
+ -->
17
+ <script setup lang="ts" generic="T extends Ui3nAutocompleteOptionBase">
18
+ import { computed, ref, watch } from 'vue';
19
+ import cloneDeep from 'lodash/cloneDeep';
20
+ import isEmpty from 'lodash/isEmpty';
21
+ import size from 'lodash/size';
22
+ import Ui3nChip from '../ui3n-chip/ui3n-chip.vue';
23
+ import Ui3nMenu from '../ui3n-menu/ui3n-menu.vue';
24
+ import Ui3nHtml from '../../directives/ui3n-html';
25
+ import { markSearch, getRandomId } from '@/utils';
26
+ import type { Nullable } from '@/components/types';
27
+ import {
28
+ Ui3nAutocompleteOptionBase,
29
+ Ui3nAutocompleteProps,
30
+ Ui3nAutocompleteEmits,
31
+ Ui3nAutocompleteSlots,
32
+ } from './types';
33
+
34
+ const vUi3nHtml = Ui3nHtml;
35
+
36
+ const props = withDefaults(defineProps<Ui3nAutocompleteProps<T>>(), {
37
+ clearOnSelect: false,
38
+ filterKeys: () => [],
39
+ filterMode: 'every',
40
+ hideSelected: false,
41
+ items: () => [],
42
+ itemTitle: 'name',
43
+ itemValue: 'id',
44
+ });
45
+ const emits = defineEmits<Ui3nAutocompleteEmits<T>>();
46
+ defineSlots<Ui3nAutocompleteSlots<T>>();
47
+
48
+ const componentId = `auto-${getRandomId(4)}`;
49
+
50
+ const query = ref('');
51
+ const isMenuOpen = ref(false);
52
+ const activatorEl = ref<Nullable<HTMLDivElement>>(null);
53
+ const inputEl = ref<Nullable<HTMLInputElement>>(null);
54
+ const menuBodyEl = ref<Nullable<HTMLDivElement>>(null);
55
+ const activeItemsIndex = ref<Nullable<number>>(null);
56
+
57
+ const ids = computed(() => props.returnObject
58
+ ? (props.modelValue as T[]).map(item => item.id)
59
+ : (props.modelValue as Array<T[keyof T]>));
60
+
61
+ const displayValue = computed(() => {
62
+ if (props.chips) return '';
63
+
64
+ if (props.returnObject) {
65
+ return (props.modelValue as T[]).map(v => v[props.itemTitle] || '?').join(', ');
66
+ }
67
+
68
+ return props.modelValue.join(', ');
69
+ });
70
+
71
+ const filteredItems = computed(() => {
72
+ if (props.customFilter) {
73
+ return props.items
74
+ .filter(item => props.customFilter!(item, query.value))
75
+ .filter(item => {
76
+ if (!props.hideSelected) return true;
77
+
78
+ return props.returnObject
79
+ ? !(ids.value as string[]).includes(item.id as string)
80
+ : !(ids.value as Array<T[keyof T]>).includes(item as T[keyof T]);
81
+ });
82
+ }
83
+
84
+ return props.items
85
+ .filter(item => (item[props.itemTitle] as string).toLowerCase().includes(query.value.toLowerCase()))
86
+ .filter(item => {
87
+ if (!props.hideSelected) return true;
88
+
89
+ return props.returnObject
90
+ ? !(ids.value as string[]).includes(item.id as string)
91
+ : !(ids.value as Array<T[keyof T]>).includes(item as T[keyof T]);
92
+ });
93
+ });
94
+
95
+ function onInput() {
96
+ emits('update:search', query.value);
97
+ }
98
+
99
+ function onBlur() {
100
+ emits('update:focused', false)
101
+ activeItemsIndex.value = null;
102
+ query.value = '';
103
+ }
104
+
105
+ function onItemClick(item: T) {
106
+ if (props.disabled) return;
107
+
108
+ if (!props.multiple) {
109
+ const newValue = props.returnObject ? [item] : [item[props.itemValue]];
110
+ emits('update:modelValue', newValue);
111
+ return;
112
+ }
113
+
114
+ const updatedValue = cloneDeep(props.modelValue);
115
+ const index = props.returnObject
116
+ ? (props.modelValue as T[]).findIndex(v => v.id === item.id)
117
+ : props.modelValue.findIndex(v => v === item[props.itemValue]);
118
+
119
+ if (index > -1) {
120
+ updatedValue.splice(index, 1);
121
+ } else {
122
+ // @ts-ignore
123
+ updatedValue.push(props.returnObject ? item : item[props.itemValue]);
124
+ }
125
+ emits('update:modelValue', updatedValue);
126
+
127
+ props.clearOnSelect && (query.value = '');
128
+ }
129
+
130
+ function onChipClose(item: T | T[keyof T]) {
131
+ if (props.disabled) return;
132
+
133
+ const index = props.returnObject
134
+ ? (props.modelValue as T[]).findIndex(v => v.id === (item as T).id)
135
+ : (props.modelValue as Array<T[keyof T]>).findIndex(v => v === item);
136
+ const updatedModelValue = cloneDeep(props.modelValue);
137
+ updatedModelValue.splice(index, 1);
138
+ emits('update:modelValue', updatedModelValue);
139
+ }
140
+
141
+ function handlePressingDownKey() {
142
+ if (activeItemsIndex.value === null) {
143
+ activeItemsIndex.value = 0;
144
+ menuBodyEl.value && menuBodyEl.value.focus();
145
+ return;
146
+ }
147
+
148
+ if (activeItemsIndex.value >= 0 && activeItemsIndex.value < size(filteredItems.value) - 1) {
149
+ activeItemsIndex.value += 1;
150
+ }
151
+ }
152
+
153
+ function handlePressingUpKey() {
154
+ if (activeItemsIndex.value === null) {
155
+ activeItemsIndex.value = size(filteredItems.value) - 1;
156
+ menuBodyEl.value && menuBodyEl.value.focus();
157
+ return;
158
+ }
159
+
160
+ if (activeItemsIndex.value > 0 && activeItemsIndex.value < size(filteredItems.value)) {
161
+ activeItemsIndex.value -= 1;
162
+ }
163
+ }
164
+
165
+ function handlePressingEscOrTabKeys() {
166
+ emits('update:focused', false)
167
+ isMenuOpen.value = false;
168
+ activeItemsIndex.value = null;
169
+ query.value = '';
170
+ inputEl.value && inputEl.value.blur();
171
+ activatorEl.value && activatorEl.value.blur();
172
+ }
173
+
174
+ function handlePressingEnterKey() {
175
+ if (activeItemsIndex.value === null) {
176
+ isMenuOpen.value = true;
177
+ activeItemsIndex.value = 0;
178
+ menuBodyEl.value && menuBodyEl.value.focus();
179
+ return;
180
+ }
181
+
182
+ const item = filteredItems.value[activeItemsIndex.value!];
183
+ onItemClick(item);
184
+ activeItemsIndex.value = null;
185
+ }
186
+
187
+ function onKeydown(event: KeyboardEvent, key: 'down' | 'up' | 'esc' | 'enter' | 'tab') {
188
+ if (isEmpty(filteredItems.value)) return;
189
+
190
+ switch (key) {
191
+ case 'down':
192
+ event.preventDefault();
193
+ event.stopPropagation();
194
+ handlePressingDownKey();
195
+ break;
196
+ case 'up':
197
+ event.preventDefault();
198
+ event.stopPropagation();
199
+ handlePressingUpKey();
200
+ break;
201
+ case 'esc':
202
+ case 'tab':
203
+ handlePressingEscOrTabKeys();
204
+ break;
205
+ case 'enter':
206
+ event.preventDefault();
207
+ event.stopPropagation();
208
+ handlePressingEnterKey();
209
+ break;
210
+ }
211
+ }
212
+
213
+ watch(
214
+ () => size(filteredItems.value),
215
+ (val, oVal) => {
216
+ if (val !== oVal) {
217
+ activeItemsIndex.value = null;
218
+ }
219
+ },
220
+ );
221
+ </script>
222
+
223
+ <template>
224
+ <div :class="$style.ui3nAutocomplete">
225
+ <ui3n-menu v-model="isMenuOpen" :offset-x="2" :offset-y="4" :disabled="disabled">
226
+ <div ref="activatorEl" :class="$style.trigger">
227
+ <template v-if="chips">
228
+ <template v-for="(item, index) in modelValue" :key="item.id">
229
+ <slot name="chip" :item="item" :index="index">
230
+ <ui3n-chip
231
+ round
232
+ closeable
233
+ @close="onChipClose(item)"
234
+ >
235
+ {{ returnObject ? (item as T)[props.itemTitle] : item }}
236
+ </ui3n-chip>
237
+ </slot>
238
+ </template>
239
+ </template>
240
+
241
+ <template v-else>
242
+ <div :class="$style.displayValue">{{ displayValue }}</div>
243
+ </template>
244
+
245
+ <input
246
+ ref="inputEl"
247
+ v-model="query"
248
+ type="text"
249
+ :class="$style.input"
250
+ :disabled="disabled"
251
+ @input="onInput"
252
+ @focusin="emits('update:focused', true)"
253
+ @focusout="onBlur"
254
+ @keydown.down="onKeydown($event, 'down')"
255
+ @keydown.up="onKeydown($event, 'up')"
256
+ @keydown.esc="onKeydown($event, 'esc')"
257
+ @keydown.enter="onKeydown($event, 'enter')"
258
+ @keydown.tab="onKeydown($event, 'tab')"
259
+ />
260
+ </div>
261
+
262
+ <template #menu>
263
+ <div ref="menuBodyEl" :class="$style.body">
264
+ <template v-if="size(filteredItems)">
265
+ <template v-for="(item, index) in filteredItems" :key="item.id">
266
+ <div
267
+ :id="`${componentId}-${index}`"
268
+ :class="[
269
+ $style.item,
270
+ activeItemsIndex === index && $style.itemSelected,
271
+ disabled && $style.itemDisabled
272
+ ]"
273
+ @click.stop.prevent="onItemClick(item)"
274
+ >
275
+ <slot name="item" :item="item" :index="index" :query="query">
276
+ <span v-ui3n-html="markSearch(item[props.itemTitle] as string, query)" />
277
+ </slot>
278
+ </div>
279
+ </template>
280
+ </template>
281
+
282
+ <div v-if="!size(filteredItems) && noDataText" :class="$style.noData">
283
+ <slot name="noDataText">
284
+ {{ noDataText }}
285
+ </slot>
286
+ </div>
287
+ </div>
288
+ </template>
289
+ </ui3n-menu>
290
+ </div>
291
+ </template>
292
+
293
+ <style lang="scss" module>
294
+ .ui3nAutocomplete {
295
+ --autocomplete-min-height: var(--spacing-ml);
296
+
297
+ position: relative;
298
+ width: 100%;
299
+ min-height: var(--autocomplete-min-height);
300
+
301
+ :global(.match-search) {
302
+ font-weight: 600;
303
+ color: var(--color-text-control-accent-default) !important;
304
+ }
305
+ }
306
+
307
+ .trigger {
308
+ display: flex;
309
+ width: 100%;
310
+ flex-wrap: wrap;
311
+ justify-content: flex-start;
312
+ align-items: flex-start;
313
+ gap: var(--spacing-xs);
314
+ }
315
+
316
+ .displayValue {
317
+ font-size: var(--font-12);
318
+ line-height: var(--autocomplete-min-height);
319
+ font-weight: 400;
320
+ color: var(--color-text-control-primary-default);
321
+ }
322
+
323
+ .input {
324
+ border-radius: var(--spacing-xs);
325
+ padding: 0 var(--spacing-s);
326
+ flex-grow: 1;
327
+ height: var(--autocomplete-min-height);
328
+ font-size: var(--font-12);
329
+ line-height: var(--font-16);
330
+ font-weight: 400;
331
+ color: var(--color-text-control-primary-default);
332
+ border: none;
333
+ outline: none;
334
+
335
+ &::placeholder {
336
+ color: var(--color-text-control-secondary-default);
337
+ }
338
+
339
+ &:hover,
340
+ &:focus {
341
+ height: calc(var(--autocomplete-min-height) - 2px);
342
+ border: 1px solid var(--color-border-control-accent-focused);
343
+ }
344
+ }
345
+
346
+ .body {
347
+ position: relative;
348
+ background-color: var(--color-bg-control-secondary-default);
349
+ padding: var(--spacing-xs);
350
+ }
351
+
352
+ .item {
353
+ display: flex;
354
+ min-height: var(--spacing-l);
355
+ justify-content: flex-start;
356
+ align-items: center;
357
+ padding: var(--spacing-xs) var(--spacing-s);
358
+ font-size: var(--font-13);
359
+ line-height: var(--font-16);
360
+ font-weight: 500;
361
+ color: var(--color-text-control-primary-default);
362
+ border-radius: var(--spacing-xs);
363
+ user-select: none;
364
+ cursor: pointer;
365
+
366
+ &.itemSelected,
367
+ &:hover {
368
+ background-color: var(--color-bg-control-primary-hover);
369
+ color: var(--color-text-control-accent-default);
370
+ }
371
+
372
+ &.itemDisabled {
373
+ opacity: 0.7;
374
+ pointer-events: none;
375
+ }
376
+ }
377
+
378
+ .noData {
379
+ display: flex;
380
+ min-height: var(--spacing-l);
381
+ justify-content: flex-start;
382
+ align-items: center;
383
+ padding: var(--spacing-xs) var(--spacing-s);
384
+ font-size: var(--font-13);
385
+ line-height: var(--font-16);
386
+ font-weight: 500;
387
+ color: var(--color-text-control-primary-default);
388
+ border-radius: var(--spacing-xs);
389
+ }
390
+ </style>
@@ -42,6 +42,10 @@ const mainCssClasses = computed(() => {
42
42
 
43
43
  return val;
44
44
  });
45
+
46
+ function onClick() {
47
+ emits('close');
48
+ }
45
49
  </script>
46
50
 
47
51
  <template>
@@ -62,7 +66,7 @@ const mainCssClasses = computed(() => {
62
66
  color="transparent"
63
67
  icon="round-close"
64
68
  icon-color="var(--color-icon-control-primary-default)"
65
- @click="emits('close')"
69
+ @click.stop.prevent="onClick"
66
70
  />
67
71
  </div>
68
72
  </template>
@@ -374,7 +374,6 @@
374
374
 
375
375
  .icon {
376
376
  position: relative;
377
- top: -1px;
378
377
  }
379
378
  }
380
379
 
@@ -1,6 +1,7 @@
1
1
  import { VNode } from 'vue';
2
2
 
3
3
  export interface Ui3nMenuProps {
4
+ modelValue?: boolean;
4
5
  positionStrategy?: 'absolute' | 'fixed';
5
6
  offsetX?: number;
6
7
  offsetY?: number;
@@ -15,6 +16,7 @@ export interface Ui3nMenuEmits {
15
16
  (ev: 'close'): void;
16
17
  (ev: 'closed'): void;
17
18
  (ev: 'click-outside'): void;
19
+ (ev: 'update:modelValue', value: boolean): void;
18
20
  }
19
21
 
20
22
  export interface Ui3nMenuSlots {
@@ -49,11 +49,13 @@
49
49
 
50
50
  isShow.value = !isShow.value;
51
51
  isShow.value ? emits('open') : emits('close');
52
+ emits('update:modelValue', isShow.value);
52
53
  }
53
54
 
54
55
  function onContentClick() {
55
56
  isShow.value = false;
56
57
  emits('close');
58
+ emits('update:modelValue', isShow.value);
57
59
  }
58
60
 
59
61
  function onClickOutside() {
@@ -61,6 +63,7 @@
61
63
  if (props.closeOnClickOutside) {
62
64
  isShow.value = false;
63
65
  emits('close');
66
+ emits('update:modelValue', isShow.value);
64
67
  }
65
68
  }
66
69
 
@@ -70,13 +73,22 @@
70
73
  val ? emits('opened') : emits('closed');
71
74
  },
72
75
  );
76
+
77
+ watch(
78
+ () => props.modelValue,
79
+ val => {
80
+ if (isShow.value !== val) {
81
+ isShow.value = val;
82
+ }
83
+ }, { immediate: true },
84
+ );
73
85
  </script>
74
86
 
75
87
  <template>
76
88
  <div ref="menuElement" :class="$style.ui3nMenu">
77
89
  <div
78
90
  ref="menuTriggerElement"
79
- :class="$style.ui3nMenuTrigger"
91
+ :class="[$style.ui3nMenuTrigger, disabled && $style.ui3nMenuTriggerDisabled]"
80
92
  @click.stop="toggleMenu"
81
93
  >
82
94
  <slot />
@@ -109,6 +121,11 @@
109
121
  .ui3nMenuTrigger {
110
122
  position: relative;
111
123
  max-width: max-content;
124
+
125
+ &.ui3nMenuTriggerDisabled {
126
+ pointer-events: none;
127
+ cursor: default;
128
+ }
112
129
  }
113
130
 
114
131
  .ui3nMenuContent {