@v1nt1248/3nclient-lib 0.2.96 → 0.2.98

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
@@ -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.2.96",
5
+ "version": "0.2.98",
6
6
  "description": "Library for 3NWeb clients",
7
7
  "type": "module",
8
8
  "files": [
@@ -107,7 +107,7 @@
107
107
  "vue-eslint-parser": "10.4.0",
108
108
  "vue-tsc": "3.2.5"
109
109
  },
110
- "packageManager": "pnpm@10.26.0",
110
+ "packageManager": "pnpm@10.32.1",
111
111
  "pnpm": {
112
112
  "peerDependencyRules": {
113
113
  "ignoreMissing": [
@@ -104,6 +104,14 @@ import type {
104
104
  } from './ui3n-autocomplete/types';
105
105
  import Ui3nSlider from './ui3n-slider/ui3n-slider.vue';
106
106
  import type { UI3nSliderProps, UI3nSliderEmits } from './ui3n-slider/types';
107
+ import Ui3nSelector from './ui3n-selector/ui3n-selector.vue';
108
+ import type {
109
+ Ui3nSelectorOptionBase,
110
+ Ui3nSelectorProps,
111
+ Ui3nSelectorEmits,
112
+ Ui3nSelectorSlots,
113
+ Ui3nSelectorItemDisplayingFunction,
114
+ } from './ui3n-selector/types';
107
115
 
108
116
  export {
109
117
  Ui3nIcon,
@@ -219,4 +227,10 @@ export {
219
227
  Ui3nSlider,
220
228
  UI3nSliderProps,
221
229
  UI3nSliderEmits,
230
+ Ui3nSelector,
231
+ Ui3nSelectorProps,
232
+ Ui3nSelectorEmits,
233
+ Ui3nSelectorSlots,
234
+ Ui3nSelectorOptionBase,
235
+ Ui3nSelectorItemDisplayingFunction,
222
236
  };
@@ -1,5 +1,6 @@
1
1
  <script lang="ts" setup>
2
2
  import { computed, onMounted, ref, watch, useCssModule } from 'vue';
3
+ import isEmpty from 'lodash/isEmpty';
3
4
  import Ui3nIcon from '../ui3n-icon/ui3n-icon.vue';
4
5
  import Ui3nButton from '../ui3n-button/ui3n-button.vue';
5
6
  import type { Ui3nInputEmits, Ui3nInputProps } from './types';
@@ -34,6 +35,7 @@
34
35
  const mainCssClasses = computed(() => {
35
36
  const val = [css.ui3nInput];
36
37
  props.label && val.push(css.withLabel);
38
+ isEmpty(props.rules) && val.push(css.withoutValidation);
37
39
  props.icon && val.push(css.withIcon);
38
40
  props.clearable && text.value && val.push(css.clearable);
39
41
  props.disabled && val.push(css.disabled);
@@ -241,6 +243,10 @@
241
243
  padding: 1px 1px 15px 1px;
242
244
  border-radius: var(--spacing-xs);
243
245
 
246
+ &.withoutValidation {
247
+ padding-bottom: 0;
248
+ }
249
+
244
250
  &:hover {
245
251
  .ui3nInputField:not(:focus-within) {
246
252
  background-color: var(--color-bg-control-secondary-hover);
@@ -0,0 +1,54 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import type { VNode } from 'vue';
3
+ import type { PrepareComponentEmits, Nullable } from '../../types';
4
+
5
+ export interface Ui3nSelectorOptionBase {
6
+ id: string;
7
+ [key: string]: any;
8
+ }
9
+
10
+ export type Ui3nSelectorItemDisplayingFunction<T extends Ui3nSelectorOptionBase> = (v: T) => string;
11
+
12
+ export type Ui3nSelectorValue<T extends Ui3nSelectorOptionBase> = T | T[keyof T] | null;
13
+
14
+ export interface Ui3nSelectorProps<T extends Ui3nSelectorOptionBase> {
15
+ modelValue: Ui3nSelectorValue<T>;
16
+ label?: string;
17
+ placeholder?: string;
18
+ items?: T[];
19
+ itemValue?: keyof T;
20
+ itemDisplay?: keyof T | Ui3nSelectorItemDisplayingFunction<T>;
21
+ customFilter?: (value: Ui3nSelectorValue<T>, query: string) => boolean;
22
+ returnObject?: boolean;
23
+ clearable?: boolean;
24
+ withSearch?: boolean;
25
+ hideSelected?: boolean;
26
+ noDataText?: string;
27
+ disabled?: boolean;
28
+ }
29
+
30
+ export interface Ui3nSelectorEventsMap<T extends Ui3nSelectorOptionBase> {
31
+ 'update:modelValue': Ui3nSelectorValue<T>;
32
+ 'list:open': undefined;
33
+ 'list:close': undefined;
34
+ focus: undefined;
35
+ blur: undefined;
36
+ }
37
+
38
+ export type Ui3nSelectorEvents = keyof Ui3nSelectorEventsMap<any>;
39
+
40
+ // export interface Ui3nSelectorEmits<T extends Ui3nSelectorOptionBase> {
41
+ // (event: 'update:modelValue', value: T | T[keyof T] | null): void;
42
+ // (event: 'list:open'): void;
43
+ // (event: 'list:close'): void;
44
+ // (event: 'focus'): void;
45
+ // (event: 'blur'): void;
46
+ // }
47
+
48
+ export type Ui3nSelectorEmits<T extends Ui3nSelectorOptionBase> = PrepareComponentEmits<Ui3nSelectorEventsMap<T>>;
49
+
50
+ export interface Ui3nSelectorSlots<T extends Ui3nSelectorOptionBase> {
51
+ noDataText?: () => VNode;
52
+ item?: (props: { item: T; index: number; activeIndex: Nullable<number>; query?: string }) => VNode;
53
+ selection?: (props: { value: Ui3nSelectorValue<T>; selectedItem: T | undefined }) => VNode;
54
+ }
@@ -0,0 +1,486 @@
1
+ <!--
2
+ Copyright (C) 2026 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 Ui3nSelectorOptionBase">
18
+ import { ref, computed, type ComputedRef, useTemplateRef, watch } from 'vue';
19
+ import isEmpty from 'lodash/isEmpty';
20
+ import size from 'lodash/size';
21
+ import { getRandomId } from '@/utils';
22
+ import type { Nullable } from '@/types';
23
+ import type {
24
+ Ui3nSelectorOptionBase,
25
+ Ui3nSelectorProps,
26
+ Ui3nSelectorEmits,
27
+ Ui3nSelectorSlots,
28
+ Ui3nSelectorItemDisplayingFunction,
29
+ } from './types';
30
+ import Ui3nButton from '../ui3n-button/ui3n-button.vue';
31
+ import Ui3nIcon from '../ui3n-icon/ui3n-icon.vue';
32
+ import Ui3nMenu from '../ui3n-menu/ui3n-menu.vue';
33
+
34
+ const props = withDefaults(defineProps<Ui3nSelectorProps<T>>(), {
35
+ label: '',
36
+ items: () => [] as T[],
37
+ itemValue: 'id',
38
+ itemDisplay: 'id' as keyof T,
39
+ clearable: false,
40
+ withSearch: false,
41
+ returnObject: false,
42
+ noDataText: '',
43
+ });
44
+ const emits = defineEmits<Ui3nSelectorEmits<T>>();
45
+ defineSlots<Ui3nSelectorSlots<T>>();
46
+
47
+ const componentId = `select-${getRandomId(4)}`;
48
+
49
+ const activatorEl = useTemplateRef<HTMLDivElement>('activator');
50
+ const bodyEl = useTemplateRef<HTMLDivElement>('body');
51
+ const isMenuOpen = ref(false);
52
+ const isFocused = ref(false);
53
+ const activeItemIndex = ref<Nullable<number>>(null);
54
+
55
+ const query = ref('');
56
+ const processedQuery = computed(() => query.value.toLowerCase());
57
+
58
+ const filteredItems = computed(() => {
59
+ return props.items
60
+ .filter(item => {
61
+ if (props.customFilter) {
62
+ return props.customFilter(item, query.value);
63
+ }
64
+
65
+ const itemDisplayingVal = getItemTitle(item, true);
66
+ return itemDisplayingVal.includes(processedQuery.value);
67
+ })
68
+ .filter(item => {
69
+ if (!props.hideSelected) {
70
+ return true;
71
+ }
72
+
73
+ return props.returnObject
74
+ ? (props.modelValue as T).id !== item.id
75
+ : (props.modelValue as T[keyof T]) !== item[props.itemValue];
76
+ });
77
+ }) as ComputedRef<T[]>;
78
+
79
+ const displayingSelection = computed(() => {
80
+ if (!props.modelValue) {
81
+ return '';
82
+ }
83
+
84
+ if (props.returnObject && typeof props.modelValue === 'object') {
85
+ const selectedItem = props.items.find(item => item.id === (props.modelValue as T).id);
86
+ return selectedItem ? getItemTitle(selectedItem) : JSON.stringify(props.modelValue);
87
+ }
88
+
89
+ if (props.returnObject && typeof props.returnObject !== 'object') {
90
+ return JSON.stringify(props.modelValue);
91
+ }
92
+
93
+ const selectedItem = props.items.find(item => item[props.itemValue] === props.modelValue);
94
+ return selectedItem ? getItemTitle(selectedItem) : props.modelValue;
95
+ });
96
+
97
+ function getItemTitle(value: T | T[keyof T], withProcessing?: boolean): string {
98
+ if (props.returnObject || typeof value === 'object') {
99
+ const isItemDisplayFunction = typeof props.itemDisplay === 'function';
100
+ const val = isItemDisplayFunction
101
+ ? (props.itemDisplay as Ui3nSelectorItemDisplayingFunction<T>)(value as T)
102
+ : value
103
+ ? ((value as T)[props.itemDisplay as keyof T] as string)
104
+ : '';
105
+ return withProcessing ? val.toLowerCase() : val;
106
+ }
107
+
108
+ return withProcessing
109
+ ? value
110
+ ? (value as T[keyof T] as string).toLowerCase()
111
+ : ''
112
+ : value
113
+ ? (value as T[keyof T] as string)
114
+ : '';
115
+ }
116
+
117
+ function onFocus() {
118
+ isFocused.value = true;
119
+ emits('focus');
120
+ }
121
+
122
+ function onBlur() {
123
+ setTimeout(() => {
124
+ isFocused.value = false;
125
+ emits('blur');
126
+ }, 100);
127
+ }
128
+
129
+ function onItemClick(item: T) {
130
+ if (props.disabled) {
131
+ return;
132
+ }
133
+
134
+ const newValue = props.returnObject ? item : item[props.itemValue];
135
+ emits('update:modelValue', newValue);
136
+ query.value = '';
137
+ isMenuOpen.value = false;
138
+ }
139
+
140
+ function resetValue() {
141
+ emits('update:modelValue', null);
142
+ isMenuOpen.value = false;
143
+ activeItemIndex.value = null;
144
+ query.value = '';
145
+ }
146
+
147
+ function handlePressingDownKey() {
148
+ if (activeItemIndex.value === null) {
149
+ activeItemIndex.value = 0;
150
+ bodyEl.value && bodyEl.value.focus();
151
+ return;
152
+ }
153
+
154
+ if (activeItemIndex.value >= 0 && activeItemIndex.value < size(filteredItems.value) - 1) {
155
+ activeItemIndex.value += 1;
156
+ }
157
+ }
158
+
159
+ function handlePressingUpKey() {
160
+ if (activeItemIndex.value === null) {
161
+ activeItemIndex.value = size(filteredItems.value) - 1;
162
+ bodyEl.value && bodyEl.value.focus();
163
+ return;
164
+ }
165
+
166
+ if (activeItemIndex.value > 0 && activeItemIndex.value < size(filteredItems.value)) {
167
+ activeItemIndex.value -= 1;
168
+ }
169
+ }
170
+
171
+ function handlePressingEscOrTabKeys() {
172
+ emits('blur');
173
+ isMenuOpen.value = false;
174
+ bodyEl.value!.blur();
175
+ activatorEl.value && activatorEl.value.blur();
176
+ }
177
+
178
+ function handlePressingEnterKey() {
179
+ if (activeItemIndex.value === null && !isMenuOpen.value) {
180
+ isMenuOpen.value = true;
181
+ bodyEl.value && bodyEl.value.focus();
182
+ return;
183
+ }
184
+
185
+ if (activeItemIndex.value === null && isMenuOpen.value) {
186
+ isMenuOpen.value = false;
187
+ return;
188
+ }
189
+
190
+ if (activeItemIndex.value !== null) {
191
+ const item = filteredItems.value[activeItemIndex.value!];
192
+ onItemClick(item);
193
+ activeItemIndex.value = null;
194
+ query.value = '';
195
+ }
196
+ }
197
+
198
+ function onKeydown(event: KeyboardEvent, key: 'down' | 'up' | 'esc' | 'enter' | 'tab') {
199
+ switch (key) {
200
+ case 'down':
201
+ event.preventDefault();
202
+ event.stopPropagation();
203
+ handlePressingDownKey();
204
+ break;
205
+ case 'up':
206
+ event.preventDefault();
207
+ event.stopPropagation();
208
+ handlePressingUpKey();
209
+ break;
210
+ case 'esc':
211
+ case 'tab':
212
+ handlePressingEscOrTabKeys();
213
+ break;
214
+ case 'enter':
215
+ event.preventDefault();
216
+ event.stopPropagation();
217
+ handlePressingEnterKey();
218
+ break;
219
+ }
220
+ }
221
+
222
+ watch(
223
+ () => isMenuOpen.value,
224
+ (val, oVal) => {
225
+ if (val !== oVal && val) {
226
+ if (props.modelValue) {
227
+ const selectedItemIndex = filteredItems.value.findIndex(item => {
228
+ if (typeof props.modelValue === 'object') {
229
+ return item.id === (props.modelValue as T).id;
230
+ }
231
+
232
+ return item[props.itemValue] === props.modelValue;
233
+ });
234
+ activeItemIndex.value = selectedItemIndex >= 0 ? selectedItemIndex : 0;
235
+ }
236
+
237
+ emits('list:open');
238
+ } else if (val !== oVal && !val) {
239
+ activeItemIndex.value = null;
240
+ query.value = '';
241
+ emits('list:close');
242
+ }
243
+ },
244
+ );
245
+ </script>
246
+
247
+ <template>
248
+ <div :class="$style.ui3nSelector">
249
+ <label
250
+ v-if="label"
251
+ :class="$style.label"
252
+ >
253
+ {{ label }}
254
+ </label>
255
+
256
+ <ui3n-menu
257
+ v-model="isMenuOpen"
258
+ :offset-x="-3"
259
+ :offset-y="4"
260
+ position-autoupdate
261
+ :content-border-radius="8"
262
+ :content-styles="{ width: '100%' }"
263
+ :class="$style.menu"
264
+ :disabled="disabled"
265
+ >
266
+ <div
267
+ ref="activator"
268
+ tabindex="0"
269
+ :class="[
270
+ $style.trigger,
271
+ isFocused && $style.triggerFocused,
272
+ clearable && modelValue && $style.clearable,
273
+ placeholder && !modelValue && !disabled && $style.placeholder,
274
+ disabled && $style.triggerDisabled,
275
+ ]"
276
+ @focusin="onFocus"
277
+ @focusout="onBlur"
278
+ @keydown.down="onKeydown($event, 'down')"
279
+ @keydown.up="onKeydown($event, 'up')"
280
+ @keydown.esc="onKeydown($event, 'esc')"
281
+ @keydown.enter="onKeydown($event, 'enter')"
282
+ @keydown.tab="onKeydown($event, 'tab')"
283
+ >
284
+ <slot
285
+ name="selection"
286
+ :value="modelValue"
287
+ :selected-item="activeItemIndex !== null ? filteredItems[activeItemIndex] : undefined"
288
+ >
289
+ {{ displayingSelection || placeholder || '' }}
290
+ </slot>
291
+
292
+ <ui3n-button
293
+ v-if="clearable && modelValue"
294
+ type="icon"
295
+ size="small"
296
+ color="transparent"
297
+ icon="round-close"
298
+ icon-size="16"
299
+ icon-color="var(--color-icon-block-primary-default)"
300
+ :disabled="disabled"
301
+ :class="$style.clearBtn"
302
+ @click.stop.prevent="resetValue"
303
+ />
304
+
305
+ <ui3n-icon
306
+ icon="outline-arrow-drop-down"
307
+ color="var(--color-icon-block-primary-default)"
308
+ size="16"
309
+ :vertical-flip="isMenuOpen"
310
+ :class="$style.menuIcon"
311
+ />
312
+ </div>
313
+
314
+ <template #menu>
315
+ <div
316
+ v-if="!isEmpty(filteredItems) || (isEmpty(filteredItems) && noDataText)"
317
+ ref="body"
318
+ :class="$style.body"
319
+ >
320
+ <template v-if="!isEmpty(filteredItems)">
321
+ <template
322
+ v-for="(item, index) in filteredItems"
323
+ :key="item.id"
324
+ >
325
+ <div
326
+ :id="`${componentId}-${index}`"
327
+ :class="[
328
+ $style.item,
329
+ activeItemIndex === index && $style.itemSelected,
330
+ disabled && $style.itemDisabled,
331
+ ]"
332
+ @click.stop.prevent="onItemClick(item)"
333
+ >
334
+ <slot
335
+ name="item"
336
+ :item="item"
337
+ :index="index"
338
+ :active-index="activeItemIndex"
339
+ :query="query"
340
+ >
341
+ {{ getItemTitle(item) }}
342
+ </slot>
343
+ </div>
344
+ </template>
345
+ </template>
346
+
347
+ <div
348
+ v-if="isEmpty(filteredItems) && noDataText"
349
+ :class="$style.noData"
350
+ >
351
+ <slot name="noDataText">
352
+ {{ noDataText }}
353
+ </slot>
354
+ </div>
355
+ </div>
356
+ </template>
357
+ </ui3n-menu>
358
+ </div>
359
+ </template>
360
+
361
+ <style lang="scss" module>
362
+ @use '@/assets/styles/mixins' as mixins;
363
+
364
+ .ui3nSelector {
365
+ --selector-activator-height: 32px;
366
+
367
+ position: relative;
368
+ width: 100%;
369
+ }
370
+
371
+ .label {
372
+ display: block;
373
+ width: 100%;
374
+ font-size: var(--font-12);
375
+ font-weight: 500;
376
+ line-height: var(--font-16);
377
+ color: var(--color-text-control-primary-default);
378
+ margin-bottom: var(--spacing-xs);
379
+ }
380
+
381
+ .menu {
382
+ width: 100%;
383
+ max-width: 100% !important;
384
+
385
+ & > div {
386
+ max-width: 100% !important;
387
+ }
388
+ }
389
+
390
+ .trigger {
391
+ display: flex;
392
+ width: 100%;
393
+ height: var(--selector-activator-height);
394
+ padding: 0 var(--spacing-ml) 0 var(--spacing-s);
395
+ justify-content: flex-start;
396
+ align-items: center;
397
+ background-color: var(--color-bg-control-secondary-default);
398
+ border-radius: var(--spacing-xs);
399
+ color: var(--color-text-control-primary-default);
400
+ font-size: var(--font-13);
401
+ font-weight: 400;
402
+ line-height: var(--font-16);
403
+ transition: all 0.2s ease-in-out;
404
+ cursor: pointer;
405
+ @include mixins.text-overflow-ellipsis();
406
+
407
+ &.placeholder {
408
+ color: var(--color-text-control-secondary-default);
409
+ font-style: italic;
410
+ }
411
+
412
+ &.triggerDisabled {
413
+ background-color: var(--color-bg-control-secondary-disabled);
414
+ color: var(--color-text-control-secondary-disabled);
415
+ cursor: default;
416
+ pointer-events: none;
417
+ }
418
+
419
+ .menuIcon {
420
+ position: absolute;
421
+ top: 8px;
422
+ right: 4px;
423
+ }
424
+
425
+ &.clearable {
426
+ padding-right: calc(var(--spacing-xxl) + var(--spacing-xs));
427
+ }
428
+
429
+ .clearBtn {
430
+ position: absolute;
431
+ top: var(--spacing-xs);
432
+ right: var(--spacing-ml);
433
+ }
434
+
435
+ &.triggerFocused {
436
+ background-color: var(--color-bg-control-secondary-focused);
437
+ outline: 1px solid var(--color-border-control-accent-focused);
438
+ }
439
+ }
440
+
441
+ .body {
442
+ position: relative;
443
+ width: 100%;
444
+ background-color: var(--color-bg-block-primary-default);
445
+ padding: var(--spacing-xs);
446
+
447
+ .noData {
448
+ display: flex;
449
+ min-height: var(--spacing-l);
450
+ justify-content: flex-start;
451
+ align-items: center;
452
+ padding: var(--spacing-xs) var(--spacing-s);
453
+ font-size: var(--font-13);
454
+ line-height: var(--font-16);
455
+ font-weight: 500;
456
+ color: var(--color-text-control-primary-default);
457
+ border-radius: var(--spacing-xs);
458
+ }
459
+
460
+ .item {
461
+ display: flex;
462
+ min-height: var(--spacing-l);
463
+ justify-content: flex-start;
464
+ align-items: center;
465
+ padding: var(--spacing-xs) var(--spacing-s);
466
+ font-size: var(--font-13);
467
+ line-height: var(--font-16);
468
+ font-weight: 500;
469
+ color: var(--color-text-control-primary-default);
470
+ border-radius: var(--spacing-xs);
471
+ user-select: none;
472
+ cursor: pointer;
473
+
474
+ &.itemSelected,
475
+ &:hover {
476
+ background-color: var(--color-bg-control-primary-hover);
477
+ color: var(--color-text-control-accent-default);
478
+ }
479
+
480
+ &.itemDisabled {
481
+ opacity: 0.7;
482
+ pointer-events: none;
483
+ }
484
+ }
485
+ }
486
+ </style>