fu-kit 1.0.1 → 1.1.0

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.
@@ -0,0 +1,472 @@
1
+ <template>
2
+ <div class="ui-tag-input" :class="{ naked }" @mousedown.stop.prevent="focusSearch">
3
+ <div class="ui-tag-input_value">
4
+ <div v-if="!!selectedItems.length" class="ui-tag-input_tags">
5
+ <div v-for="item in selectedItems" :key="item.value" class="ui-tag-input_tags-tag">
6
+ <span>{{ item.label }}</span>
7
+ <ui-icon
8
+ name="cross"
9
+ class="ui-tag-input_tags-tag-remove"
10
+ @click.stop.capture="onSelect($event, item)"
11
+ />
12
+ </div>
13
+ </div>
14
+ <div v-else class="ui-tag-input_empty">
15
+ <slot name="empty" />
16
+ </div>
17
+ <input
18
+ tabindex="0"
19
+ class="ui-tag-input_input"
20
+ :class="{ _margin: selectedItems.length }"
21
+ ref="refSearch"
22
+ v-model="search"
23
+ :placeholder="placeholder"
24
+ @keydown="onTextKeydown"
25
+ @keyup="onTextKeyup"
26
+ @focus="onTextFocus"
27
+ spellcheck="false"
28
+ />
29
+ </div>
30
+ <ui-icon
31
+ :name="isLoading ? 'spinner' : 'chevron-down'"
32
+ class="ui-tag-input_value-chevron"
33
+ :class="{ _loading: isLoading }"
34
+ />
35
+ <div class="ui-tag-input_list" ref="refList" @keydown="onArrows">
36
+ <button
37
+ class="ui-tag-input_list-item"
38
+ :class="{'_selected': modelValue.includes(item.value)}"
39
+ tabindex="-1"
40
+ type="button"
41
+ v-for="item in filteredItems"
42
+ @click="onSelect($event, item)"
43
+ >
44
+ <ui-icon name="check" class="ui-tag-input_list-item-check" />
45
+ <span class="ui-tag-input_list-item-lbl">{{ item.label }}</span>
46
+ </button>
47
+ <span v-if="!filteredItems.length" class="ui-tag-input_list-item _disabled">No items found</span>
48
+ </div>
49
+ </div>
50
+ </template>
51
+
52
+ <script>
53
+ import { computed, nextTick, ref } from 'vue'
54
+ import UiIcon from './UiIcon.vue'
55
+
56
+ const CUSTOM_OPTION = { value: Symbol('custom'), label: '+ Add new', isCustom: true }
57
+
58
+ export default {
59
+ name: 'ui-tag-input',
60
+ components: { UiIcon },
61
+ props: {
62
+ modelValue: { type: Array, default: [] },
63
+ options: { type: Array, default: [] },
64
+ allowCustom: { type: Boolean, default: false },
65
+ placeholder: { type: String, default: '' },
66
+ isLoading: { type: Boolean },
67
+ naked: { type: Boolean, default: false },
68
+ backspaceRemove: { type: Boolean, default: false },
69
+ },
70
+ emits: [ 'update:modelValue', 'add', 'remove' ],
71
+ setup (props, { emit }) {
72
+ const refSearch = ref(null)
73
+ const refList = ref(null)
74
+ const search = ref('')
75
+ const prevSearch = ref('')
76
+
77
+ const normalItems = computed(() => {
78
+ return props.options.map(option => {
79
+ if (typeof option === 'object' && option !== null) {
80
+ return option
81
+ } else {
82
+ return { value: option, label: option }
83
+ }
84
+ })
85
+ })
86
+
87
+ const filteredItems = computed(() => {
88
+ if (!search.value) return normalItems.value
89
+ const filtered = normalItems.value
90
+ .filter((i) => String(i.label).toLowerCase().includes(String(search.value).toLowerCase()))
91
+
92
+ return props.allowCustom ? [ CUSTOM_OPTION, ...filtered ] : filtered
93
+ })
94
+
95
+ const selectedItems = computed(() => props.modelValue.map(value => {
96
+ const option = normalItems.value.find(item => item.value === value)
97
+ return option || { value, label: value }
98
+ }))
99
+
100
+ const onSelect = (event, option) => {
101
+ const item = option.isCustom ? { value: search.value, label: search.value, isCustom: true } : option
102
+
103
+ const isAdding = !props.modelValue.includes(item.value)
104
+
105
+ if (isAdding) emit('add', item)
106
+ else emit('remove', item)
107
+
108
+ const newValue = isAdding
109
+ ? [ ...props.modelValue, item.value ]
110
+ : props.modelValue.filter(it => it !== item.value)
111
+
112
+ emit('update:modelValue', newValue)
113
+
114
+ search.value = ''
115
+ event.target.blur()
116
+ }
117
+
118
+ const onTextFocus = async (e) => {
119
+ // search.value = model.value
120
+ await nextTick()
121
+ e.target.setSelectionRange(0, -1)
122
+ }
123
+
124
+ const onTextKeydown = (e) => {
125
+ prevSearch.value = e.target.value
126
+ if (![ 'ArrowDown', 'ArrowUp', 'Enter', 'Escape' ].includes(e.key)) return
127
+
128
+ if (e.key === 'Escape') return document.activeElement.blur()
129
+
130
+ const nodes = Array.prototype.slice.call(refList.value.children)
131
+
132
+ if (e.key === 'Enter') {
133
+ if (nodes[0]) {
134
+ nodes[0].click()
135
+ } else {
136
+ if (props.allowCustom) onSelect(e, { label: search.value, value: search.value })
137
+
138
+ search.value = ''
139
+ }
140
+
141
+ e.preventDefault()
142
+ e.target.blur()
143
+
144
+ return
145
+ }
146
+
147
+ if (!nodes.length) return
148
+
149
+ switch (e.key) {
150
+ case 'ArrowDown':
151
+ e.preventDefault()
152
+ nodes[0].focus()
153
+ break
154
+ case 'ArrowUp':
155
+ e.preventDefault()
156
+ nodes[nodes.length - 1].focus()
157
+ break
158
+ }
159
+
160
+ e.preventDefault()
161
+ }
162
+
163
+ const onArrows = (e) => {
164
+ if (![ 'ArrowDown', 'ArrowUp', 'Escape' ].includes(e.key)) return
165
+
166
+ if (e.key === 'Escape') return document.activeElement.blur()
167
+
168
+ if (refList.value !== document.activeElement.parentElement) return
169
+
170
+ switch (e.key) {
171
+ case 'ArrowDown':
172
+ e.preventDefault()
173
+ focusJump(+1)
174
+ break
175
+ case 'ArrowUp':
176
+ e.preventDefault()
177
+ focusJump(-1)
178
+ break
179
+ }
180
+ }
181
+
182
+ const focusJump = (next = -1) => {
183
+ const foElm = document.activeElement.parentElement
184
+ if (foElm !== refList.value && foElm === refSearch.value) return
185
+
186
+ const nodes = Array.prototype.slice.call(refList.value.children)
187
+ const liRef = document.activeElement
188
+ const fi = nodes.indexOf(liRef)
189
+
190
+
191
+ if (next > 0) {
192
+ if (fi === nodes.length - 1) return nodes[0].focus()
193
+ if (nodes[fi + 1]) nodes[fi + 1].focus()
194
+ } else if (next < 0) {
195
+ if (fi === 0) return nodes[nodes.length - 1].focus()
196
+ if (nodes[fi - 1]) nodes[fi - 1].focus()
197
+ }
198
+ }
199
+
200
+ const onClickAway = () => {
201
+ search.value = ''
202
+ }
203
+
204
+ const focusSearch = () => {
205
+ if (refSearch.value !== document.activeElement) refSearch.value.focus()
206
+ }
207
+
208
+ const onTextKeyup = e => {
209
+ if (props.backspaceRemove && !e.target.value && !prevSearch.value && e.key === 'Backspace' && props.modelValue.length) {
210
+ emit('remove', selectedItems.value[selectedItems.value.length - 1])
211
+ emit('update:modelValue', props.modelValue.slice(0, props.modelValue.length - 1))
212
+ }
213
+ }
214
+
215
+ return {
216
+ refSearch,
217
+ refList,
218
+ search,
219
+ filteredItems,
220
+ selectedItems,
221
+ onTextKeydown,
222
+ onArrows,
223
+ onSelect,
224
+ onTextFocus,
225
+ onClickAway,
226
+ focusSearch,
227
+ onTextKeyup,
228
+ }
229
+ },
230
+ }
231
+ </script>
232
+
233
+ <style lang="scss" scoped>
234
+ @include fx-spin-keyframes();
235
+
236
+ .ui-tag-input {
237
+ @include typo(200);
238
+
239
+ display: flex;
240
+ box-sizing: border-box;
241
+ justify-content: stretch;
242
+ align-items: flex-start;
243
+ border-style: var(--ui-lt-border-style);
244
+ border-width: var(--ui-lt-border-width);
245
+ border-color: var(--ui-pal-lateral);
246
+ border-radius: var(--ui-lt-border-radius);
247
+ transition-duration: 240ms;
248
+ transition-timing-function: ease-in-out;
249
+ transition-property: border-color, box-shadow;
250
+ position: relative;
251
+ min-height: var(--ui-lt-h);
252
+
253
+ &.naked {
254
+ border-color: transparent;
255
+ }
256
+
257
+ &_value {
258
+ display: flex;
259
+ align-content: center;
260
+ justify-content: flex-start;
261
+ margin: spacing(100, 200);
262
+
263
+ &-chevron {
264
+ margin: spacing(200);
265
+ min-height: 100%;
266
+ transform: rotate(0);
267
+ transition: transform var(--ui-transition);
268
+
269
+
270
+ &._loading {
271
+ --icon-size: 16px;
272
+
273
+ animation: fx-spin 2s infinite;
274
+ }
275
+ }
276
+ }
277
+
278
+ &_tags {
279
+ display: flex;
280
+ gap: spacing(100);
281
+ flex-wrap: wrap;
282
+
283
+ &-tag {
284
+ padding: spacing(300);
285
+ display: flex;
286
+ align-items: center;
287
+ gap: spacing(200);
288
+ background-color: var(--pal-grey100);
289
+ cursor: default;
290
+ border-radius: 4px;
291
+
292
+ --icon-size: var(--typo-h200);
293
+
294
+ &:hover {
295
+ background-color: var(--pal-grey300);
296
+ }
297
+
298
+ &-remove {
299
+ cursor: pointer;
300
+ border-radius: var(--icon-size);
301
+ overflow: hidden;
302
+ display: block;
303
+
304
+ &:hover {
305
+ --icon-color: var(--pal-negative);
306
+ }
307
+
308
+ &._loading {
309
+ --icon-size: 0.8em;
310
+
311
+ animation: fx-spin 2s infinite;
312
+ pointer-events: none;
313
+ cursor: default;
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ &:focus-within &_input {
320
+ max-height: unset;
321
+ min-height: var(--ui-lt-h-sub);
322
+
323
+ &._margin {
324
+ margin-top: spacing(300);
325
+ }
326
+ }
327
+
328
+ &_input {
329
+ @include typo(200);
330
+
331
+ padding: 0;
332
+ font-family: var(--typo-font-ui);
333
+ color: var(--ui-pal-text);
334
+ caret-color: var(--ui-pal);
335
+ max-height: 0;
336
+ border: none;
337
+ outline: none;
338
+ background: transparent;
339
+ box-sizing: border-box;
340
+ flex: 1;
341
+ display: block;
342
+ min-width: 0;
343
+
344
+ &:focus {
345
+ outline: none;
346
+ max-height: unset;
347
+ }
348
+
349
+ &::selection {
350
+ background-color: var(--ui-pal);
351
+ color: var(--ui-pal-text-select);
352
+ }
353
+
354
+ &::placeholder {
355
+ color: var(--ui-pal-placeholder);
356
+ }
357
+ }
358
+
359
+ &_list {
360
+ @include scrollbar-awesome();
361
+
362
+ overflow: auto;
363
+ max-height: 30vh;
364
+ max-width: 100vw;
365
+ display: none;
366
+ flex-direction: column;
367
+ justify-content: stretch;
368
+ border-width: var(--ui-lt-border-width);
369
+ border-radius: var(--ui-lt-border-radius);
370
+ border-color: var(--ui-pal);
371
+ border-style: solid;
372
+ position: absolute;
373
+ left: calc(var(--ui-lt-border-width) * -1);
374
+ top: 100%;
375
+ min-width: calc(100% + var(--ui-lt-border-width) * 2);
376
+ background: var(--ui-pal-bg);
377
+ margin-top: spacing(200);
378
+ z-index: var(--lt-z-pop);
379
+
380
+ &-item {
381
+ @include typo(200, 300);
382
+
383
+ color: var(--pal-front);
384
+ padding: spacing(300);
385
+ display: flex;
386
+ justify-content: space-between;
387
+ align-items: center;
388
+ gap: spacing(300);
389
+ border: 0 none;
390
+ text-decoration: none;
391
+ border-radius: 0;
392
+ text-align: left;
393
+ font-weight: inherit;
394
+ font-family: var(--typo-font-ui);
395
+ background: transparent;
396
+ outline: none;
397
+
398
+ --icon-size: 1em;
399
+
400
+ &-lbl {
401
+ display: block;
402
+ flex: 1;
403
+ }
404
+
405
+ &-check {
406
+ visibility: hidden;
407
+ }
408
+
409
+ &._selected &-check {
410
+ visibility: visible;
411
+ }
412
+
413
+ &._selected {
414
+ color: var(--ui-pal-acc);
415
+ background-color: rgba(var(--ui-rgb), 0.5);
416
+ }
417
+
418
+ &:hover:not(._disabled)._selected {
419
+ color: var(--ui-pal-acc);
420
+ background-color: var(--ui-pal);
421
+ }
422
+
423
+ &:hover:not(._disabled) {
424
+ background-color: var(--pal-bg);
425
+ color: var(--ui-pal-text);
426
+ }
427
+
428
+ &:focus {
429
+ background-color: var(--pal-bg);
430
+ color: var(--ui-pal-text);
431
+ }
432
+
433
+ &._disabled {
434
+ user-select: none;
435
+ cursor: default;
436
+ }
437
+ }
438
+ }
439
+
440
+ &_value {
441
+ display: flex;
442
+ flex-direction: column;
443
+ flex: 1;
444
+ }
445
+
446
+ &:focus-within &_list {
447
+ display: flex;
448
+ }
449
+
450
+ &:focus-within &_value-chevron {
451
+ transform: rotateX(180deg);
452
+ }
453
+
454
+ &:hover {
455
+ outline: none;
456
+ }
457
+
458
+ &:focus-within {
459
+ outline: none;
460
+ border-color: var(--ui-pal);
461
+ }
462
+
463
+ &:focus-within &_empty {
464
+ display: none;
465
+ }
466
+
467
+ &._disabled {
468
+ border: var(--ui-lt-border-width) var(--ui-lt-disabled-border-style) var(--ui-pal-disabled-border);
469
+ background: transparent;
470
+ }
471
+ }
472
+ </style>
@@ -0,0 +1,127 @@
1
+ <template>
2
+ <div
3
+ :class="{'_disabled': disabled || $attrs.readOnly !== undefined, 'ui-time': !naked }"
4
+ v-bind="{ class: [$attrs.class, mod] }"
5
+ >
6
+ <slot />
7
+ <slot name="left" />
8
+ <input
9
+ v-bind="{...$attrs, disabled, class: undefined}"
10
+ :value="modelValue"
11
+ class="ui-time_input"
12
+ :class="{ _naked: naked }"
13
+ @input="$emit('update:modelValue', $event.target.value)"
14
+ @focus="handleFocus"
15
+ ref="inputRef"
16
+ type="time"
17
+ >
18
+ <slot name="right" />
19
+ </div>
20
+ </template>
21
+
22
+ <script>
23
+ import { computed } from 'vue'
24
+
25
+ export default {
26
+ name: 'ui-time',
27
+ props: {
28
+ disabled: { type: Boolean, default: false },
29
+ autoSelect: { type: Boolean, default: false },
30
+ naked: { type: Boolean, default: false },
31
+ modelValue: {
32
+ type: [ String, Number ],
33
+ default: '',
34
+ },
35
+ },
36
+ emits: [ 'update:modelValue' ],
37
+ expose: [ 'focus' ],
38
+ setup (props) {
39
+ const mod = computed(() => {
40
+ if (props.naked) return '_naked'
41
+ return '_line'
42
+ })
43
+ return { mod }
44
+ },
45
+ methods: {
46
+ focus () {
47
+ this.$refs.inputRef.focus()
48
+ },
49
+ handleFocus () {
50
+ if (this.autoSelect) this.$refs.inputRef.select()
51
+ },
52
+ },
53
+ }
54
+ </script>
55
+ <style lang="scss" scoped>
56
+ .ui-time {
57
+ @include typo(200);
58
+
59
+ display: flex;
60
+ gap: 0;
61
+ padding: var(--ui-input-padding, #{spacing(200, 400)});
62
+ box-sizing: border-box;
63
+ align-items: center;
64
+ justify-content: stretch;
65
+ border-style: var(--ui-lt-border-style);
66
+ border-width: var(--ui-lt-border-width);
67
+ border-color: var(--ui-pal-lateral);
68
+ border-radius: var(--ui-lt-inline-border-radius);
69
+ transition-duration: 240ms;
70
+ transition-timing-function: ease-in-out;
71
+ transition-property: border-color;
72
+ height: var(--ui-lt-h);
73
+ background: var(--ui-pal-bg);
74
+
75
+ &_input {
76
+ @include typo(200);
77
+
78
+ letter-spacing: spacing(100);
79
+ padding: 0;
80
+ font-family: var(--typo-font-ui);
81
+ color: var(--ui-pal-text);
82
+ caret-color: var(--ui-pal-text);
83
+ min-height: min(100%);
84
+ border: none;
85
+ outline: none;
86
+ background: transparent;
87
+ box-sizing: border-box;
88
+ flex: 1;
89
+ display: block;
90
+ min-width: 0;
91
+ margin: 0;
92
+
93
+ &::selection {
94
+ background-color: var(--ui-pal);
95
+ color: var(--ui-pal-text-select);
96
+ }
97
+
98
+ &::placeholder {
99
+ color: var(--ui-pal-placeholder);
100
+ }
101
+
102
+ &[disabled], &[read-only] {
103
+ cursor: not-allowed;
104
+ color: var(--ui-pal-disabled-border);
105
+ }
106
+
107
+ &._naked {
108
+ padding: var(--ui-input-padding, 0);
109
+ }
110
+ }
111
+
112
+ &:hover {
113
+ outline: none;
114
+ border-color: var(--ui-pal);
115
+ }
116
+
117
+ &:focus-within {
118
+ outline: none;
119
+ border-color: var(--ui-pal);
120
+ }
121
+
122
+ &._disabled {
123
+ border-color: var(--ui-pal-disabled-border);
124
+ border-style: var(--ui-lt-disabled-border-style);
125
+ }
126
+ }
127
+ </style>
@@ -0,0 +1,119 @@
1
+ <template>
2
+ <div class="ui-var">
3
+ <component :is="multiline ? 'ui-textarea' : 'ui-text'"
4
+ :placeholder="placeholder"
5
+ :label="label"
6
+ spellcheck="false"
7
+ :modelValue="val"
8
+ @update:modelValue="updateValue"
9
+ v-bind="{ ...$attrs }"
10
+ :class="hasError ? 'negative' : ''"
11
+ :auto-resize="autoResize"
12
+ />
13
+ <small class="ui-var_type" :class="{ _error: hasError }">
14
+ {{ typeLabel }}
15
+ </small>
16
+ </div>
17
+ </template>
18
+
19
+ <script>
20
+ import { computed, ref, watch } from 'vue'
21
+
22
+ import UiText from './UiText.vue'
23
+ import UiTextarea from './UiTextarea.vue'
24
+
25
+ const getTypeName = value => {
26
+ if (Array.isArray(value)) return 'array'
27
+ if (value === null) return 'null'
28
+
29
+ return typeof value
30
+ }
31
+
32
+ export default {
33
+ name: 'ui-var',
34
+ components: { UiText, UiTextarea },
35
+ props: {
36
+ modelValue: {
37
+ validator: modelValue => typeof modelValue !== 'undefined',
38
+ },
39
+ expect: { type: [ String, Array ] },
40
+ nullable: { type: Boolean, default: false },
41
+ multiline: { type: Boolean, default: false },
42
+ autoResize: { type: Boolean, default: false },
43
+ label: { type: String, default: '' },
44
+ placeholder: { type: String, default: '' },
45
+ },
46
+ emits: [ 'update:modelValue', 'update:isValid', 'error' ],
47
+ setup (props, context) {
48
+ const val = ref(typeof props.modelValue === 'string'
49
+ ? props.modelValue
50
+ : JSON.stringify(props.modelValue, null, props.multiline ? 2 : 0))
51
+
52
+ const type = ref(getTypeName(props.modelValue))
53
+ const hasError = ref(false)
54
+
55
+ const allowedTypes = computed(() => {
56
+ if (!props.expect) return null
57
+
58
+ const typesArray = Array.isArray(props.expect) ? props.expect : [ props.expect ]
59
+ return props.nullable ? [ ...typesArray, DATA_TYPES.NULL ] : typesArray
60
+ })
61
+
62
+ const isOnlyString = computed(() => {
63
+ return props.expect === DATA_TYPES.STRING ||
64
+ (Array.isArray(props.expect) && props.expect.length === 1 && props.expect[0] === DATA_TYPES.STRING)
65
+ })
66
+
67
+ const validateType = (newType) => {
68
+ if (allowedTypes.value && !allowedTypes.value.includes(newType)) {
69
+ context.emit('error', new Error(`Received type: ${ newType }; Expected types: ${ allowedTypes.value.join('/') }`))
70
+ context.emit('update:isValid', false)
71
+ hasError.value = true
72
+ } else {
73
+ context.emit('update:isValid', true)
74
+ hasError.value = false
75
+ }
76
+ }
77
+ validateType(type.value)
78
+
79
+ const updateValue = (value) => {
80
+ val.value = value
81
+ let newValue
82
+ try {
83
+ newValue = isOnlyString.value ? val.value : JSON.parse(val.value)
84
+ } catch (e) {
85
+ newValue = val.value
86
+ } finally {
87
+ context.emit('update:modelValue', newValue)
88
+ type.value = getTypeName(newValue)
89
+ validateType(type.value)
90
+ }
91
+ }
92
+
93
+ watch(allowedTypes, () => {
94
+ validateType(type.value)
95
+ })
96
+
97
+ const typeLabel = computed(() => {
98
+ return (hasError.value ? 'Invalid Value! Expected: ' : '') + allowedTypes.value?.join(', ')
99
+ })
100
+
101
+ return { val, type, hasError, allowedTypes, updateValue, typeLabel }
102
+ },
103
+ }
104
+ </script>
105
+
106
+ <style lang="scss" scoped>
107
+ .ui-var {
108
+ &_type {
109
+ margin: spacing(300, 0);
110
+ color: var(--pal-grey600);
111
+ cursor: default;
112
+ display: block;
113
+
114
+ &._error {
115
+ color: var(--pal-negative);
116
+ }
117
+ }
118
+ }
119
+ </style>