@flux-ui/components 3.1.2 → 3.1.4
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/dist/component/FluxAvatarGroup.vue.d.ts +17 -0
- package/dist/component/FluxButton.vue.d.ts +2 -0
- package/dist/component/FluxContextMenu.vue.d.ts +26 -0
- package/dist/component/FluxDataTable.vue.d.ts +20 -10
- package/dist/component/FluxDescriptionItem.vue.d.ts +19 -0
- package/dist/component/FluxDescriptionList.vue.d.ts +17 -0
- package/dist/component/FluxFlyout.vue.d.ts +9 -2
- package/dist/component/FluxFormCombobox.vue.d.ts +20 -0
- package/dist/component/FluxFormRating.vue.d.ts +21 -0
- package/dist/component/FluxFormTagsInput.vue.d.ts +27 -0
- package/dist/component/FluxFormTextArea.vue.d.ts +6 -1
- package/dist/component/FluxInlineEdit.vue.d.ts +41 -0
- package/dist/component/FluxMenu.vue.d.ts +1 -0
- package/dist/component/FluxMenuFlyout.vue.d.ts +22 -0
- package/dist/component/FluxTableCell.vue.d.ts +1 -0
- package/dist/component/FluxTour.vue.d.ts +35 -0
- package/dist/component/FluxTourItem.vue.d.ts +18 -0
- package/dist/component/FluxVirtualScroller.vue.d.ts +27 -0
- package/dist/component/index.d.ts +12 -0
- package/dist/component/primitive/AnchorPopup.vue.d.ts +7 -1
- package/dist/component/primitive/SelectBase.vue.d.ts +3 -0
- package/dist/composable/private/index.d.ts +1 -0
- package/dist/composable/private/useMenuFlyout.d.ts +42 -0
- package/dist/data/di.d.ts +35 -0
- package/dist/data/i18n.d.ts +7 -0
- package/dist/index.css +449 -5
- package/dist/index.js +2156 -408
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/component/FluxAvatarGroup.vue +52 -0
- package/src/component/FluxButton.vue +3 -0
- package/src/component/FluxContextMenu.vue +134 -0
- package/src/component/FluxDataTable.vue +113 -32
- package/src/component/FluxDescriptionItem.vue +43 -0
- package/src/component/FluxDescriptionList.vue +37 -0
- package/src/component/FluxDestructiveButton.vue +2 -1
- package/src/component/FluxFlyout.vue +16 -3
- package/src/component/FluxFormCombobox.vue +98 -0
- package/src/component/FluxFormRating.vue +172 -0
- package/src/component/FluxFormTagsInput.vue +249 -0
- package/src/component/FluxFormTextArea.vue +16 -1
- package/src/component/FluxInlineEdit.vue +176 -0
- package/src/component/FluxMenu.vue +13 -3
- package/src/component/FluxMenuFlyout.vue +118 -0
- package/src/component/FluxPrimaryButton.vue +2 -1
- package/src/component/FluxPrimaryLinkButton.vue +2 -1
- package/src/component/FluxPublishButton.vue +2 -1
- package/src/component/FluxSecondaryButton.vue +2 -1
- package/src/component/FluxSecondaryLinkButton.vue +2 -1
- package/src/component/FluxTableCell.vue +2 -0
- package/src/component/FluxTour.vue +332 -0
- package/src/component/FluxTourItem.vue +27 -0
- package/src/component/FluxVirtualScroller.vue +96 -0
- package/src/component/index.ts +12 -0
- package/src/component/primitive/AnchorPopup.vue +27 -0
- package/src/component/primitive/SelectBase.vue +37 -2
- package/src/composable/private/index.ts +1 -0
- package/src/composable/private/useMenuFlyout.ts +417 -0
- package/src/css/component/AvatarGroup.module.scss +22 -0
- package/src/css/component/ContextMenu.module.scss +17 -0
- package/src/css/component/DescriptionList.module.scss +98 -0
- package/src/css/component/Form.module.scss +51 -0
- package/src/css/component/FormRating.module.scss +47 -0
- package/src/css/component/InlineEdit.module.scss +45 -0
- package/src/css/component/Menu.module.scss +4 -1
- package/src/css/component/MenuFlyout.module.scss +38 -0
- package/src/css/component/Table.module.scss +16 -0
- package/src/css/component/Tour.module.scss +108 -0
- package/src/css/component/VirtualScroller.module.scss +17 -0
- package/src/css/mixin/button-active.scss +3 -1
- package/src/data/di.ts +40 -0
- package/src/data/i18n.ts +7 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<SelectBase
|
|
3
|
+
v-model:searchQuery="modelSearch"
|
|
4
|
+
:aria-invalid="error ? true : undefined"
|
|
5
|
+
:class="clsx(
|
|
6
|
+
isCondensed && $formStyle.isCondensed,
|
|
7
|
+
isSecondary && $formStyle.isSecondary,
|
|
8
|
+
error && $formStyle.isInvalid
|
|
9
|
+
)"
|
|
10
|
+
:disabled="disabled"
|
|
11
|
+
is-searchable
|
|
12
|
+
:is-creatable="isCreatable"
|
|
13
|
+
:is-loading="isLoading"
|
|
14
|
+
:is-multiple="isMultiple"
|
|
15
|
+
:options="groups"
|
|
16
|
+
:placeholder="placeholder"
|
|
17
|
+
:selected="selected"
|
|
18
|
+
@create="onCreate"
|
|
19
|
+
@deselect="onDeselect"
|
|
20
|
+
@select="onSelect"/>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script
|
|
24
|
+
lang="ts"
|
|
25
|
+
setup>
|
|
26
|
+
import type { FluxFormInputBaseProps, FluxFormSelectEntry, FluxFormSelectOption, FluxFormSelectValue } from '@flux-ui/types';
|
|
27
|
+
import { clsx } from 'clsx';
|
|
28
|
+
import { computed, ref, toRef, unref } from 'vue';
|
|
29
|
+
import { SelectBase } from '~flux/components/component/primitive';
|
|
30
|
+
import { useDisabled } from '~flux/components/composable';
|
|
31
|
+
import { useFormSelect } from '~flux/components/composable/private';
|
|
32
|
+
import { isFluxFormSelectGroup, isFluxFormSelectOption } from '~flux/components/data';
|
|
33
|
+
import $formStyle from '~flux/components/css/component/Form.module.scss';
|
|
34
|
+
|
|
35
|
+
const modelSearch = defineModel<string>('searchQuery', {
|
|
36
|
+
default: ''
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const modelValue = defineModel<FluxFormSelectValue>({
|
|
40
|
+
required: true
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const {
|
|
44
|
+
disabled: componentDisabled,
|
|
45
|
+
isMultiple,
|
|
46
|
+
options
|
|
47
|
+
} = defineProps<Pick<FluxFormInputBaseProps, 'autoFocus' | 'disabled' | 'error' | 'isCondensed' | 'isLoading' | 'isReadonly' | 'isSecondary' | 'name' | 'placeholder'> & {
|
|
48
|
+
readonly isCreatable?: boolean;
|
|
49
|
+
readonly isMultiple?: boolean;
|
|
50
|
+
readonly options: FluxFormSelectEntry[];
|
|
51
|
+
}>();
|
|
52
|
+
|
|
53
|
+
const disabled = useDisabled(toRef(() => componentDisabled));
|
|
54
|
+
const createdOptions = ref<FluxFormSelectOption[]>([]);
|
|
55
|
+
|
|
56
|
+
const allOptions = computed<FluxFormSelectEntry[]>(() => {
|
|
57
|
+
const seen = new Set<string | number | null>();
|
|
58
|
+
|
|
59
|
+
return [...options, ...createdOptions.value].filter(o => {
|
|
60
|
+
if (isFluxFormSelectGroup(o)) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (seen.has(o.value)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
seen.add(o.value);
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const {groups, selected, values} = useFormSelect(modelValue, isMultiple, allOptions, modelSearch);
|
|
74
|
+
|
|
75
|
+
function onDeselect(id: string | number | null): void {
|
|
76
|
+
if (isMultiple) {
|
|
77
|
+
modelValue.value = unref(values).filter(v => v !== id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onSelect(id: string | number | null): void {
|
|
82
|
+
if (isMultiple) {
|
|
83
|
+
modelValue.value = [...unref(values), id];
|
|
84
|
+
} else {
|
|
85
|
+
modelValue.value = id;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onCreate(query: string): void {
|
|
90
|
+
const exists = unref(allOptions).some(o => isFluxFormSelectOption(o) && o.value === query);
|
|
91
|
+
|
|
92
|
+
if (!exists) {
|
|
93
|
+
createdOptions.value = [...createdOptions.value, {label: query, value: query}];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onSelect(query);
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
:class="clsx(
|
|
4
|
+
$style.formRating,
|
|
5
|
+
disabled && $style.isDisabled,
|
|
6
|
+
error && $style.isInvalid
|
|
7
|
+
)"
|
|
8
|
+
:id="id"
|
|
9
|
+
:style="{
|
|
10
|
+
fontSize: size && `${size}px`
|
|
11
|
+
}"
|
|
12
|
+
role="slider"
|
|
13
|
+
:aria-disabled="disabled ? true : undefined"
|
|
14
|
+
:aria-invalid="error ? true : undefined"
|
|
15
|
+
:aria-readonly="isReadonly ? true : undefined"
|
|
16
|
+
:aria-valuemin="0"
|
|
17
|
+
:aria-valuemax="count"
|
|
18
|
+
:aria-valuenow="modelValue ?? 0"
|
|
19
|
+
:aria-valuetext="`${modelValue ?? 0} / ${count}`"
|
|
20
|
+
:tabindex="isInteractive ? 0 : undefined"
|
|
21
|
+
@keydown="onKeyDown"
|
|
22
|
+
@mouseleave="hoverValue = null">
|
|
23
|
+
<input
|
|
24
|
+
v-if="name"
|
|
25
|
+
type="hidden"
|
|
26
|
+
:name="name"
|
|
27
|
+
:value="modelValue ?? ''">
|
|
28
|
+
|
|
29
|
+
<button
|
|
30
|
+
v-for="star of count"
|
|
31
|
+
:key="star"
|
|
32
|
+
:class="$style.formRatingStar"
|
|
33
|
+
type="button"
|
|
34
|
+
tabindex="-1"
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
:disabled="!isInteractive"
|
|
37
|
+
:style="{
|
|
38
|
+
'--fill': fillFor(star)
|
|
39
|
+
}"
|
|
40
|
+
@click="onClick(star, $event)"
|
|
41
|
+
@mousemove="onMouseMove(star, $event)">
|
|
42
|
+
<FluxIcon
|
|
43
|
+
:class="$style.formRatingStarEmpty"
|
|
44
|
+
:name="icon"/>
|
|
45
|
+
|
|
46
|
+
<FluxIcon
|
|
47
|
+
:class="$style.formRatingStarFull"
|
|
48
|
+
:name="icon"/>
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<script
|
|
54
|
+
lang="ts"
|
|
55
|
+
setup>
|
|
56
|
+
import type { FluxFormInputBaseProps, FluxIconName } from '@flux-ui/types';
|
|
57
|
+
import { clsx } from 'clsx';
|
|
58
|
+
import { computed, ref, toRef } from 'vue';
|
|
59
|
+
import { useDisabled, useFormFieldInjection } from '~flux/components/composable';
|
|
60
|
+
import FluxIcon from './FluxIcon.vue';
|
|
61
|
+
import $style from '~flux/components/css/component/FormRating.module.scss';
|
|
62
|
+
|
|
63
|
+
const modelValue = defineModel<number | null>({
|
|
64
|
+
default: null
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const {
|
|
68
|
+
allowHalf = false,
|
|
69
|
+
clearable = false,
|
|
70
|
+
count = 5,
|
|
71
|
+
disabled: componentDisabled,
|
|
72
|
+
icon = 'star',
|
|
73
|
+
isReadonly
|
|
74
|
+
} = defineProps<Pick<FluxFormInputBaseProps, 'disabled' | 'error' | 'isReadonly' | 'name'> & {
|
|
75
|
+
readonly allowHalf?: boolean;
|
|
76
|
+
readonly clearable?: boolean;
|
|
77
|
+
readonly count?: number;
|
|
78
|
+
readonly icon?: FluxIconName;
|
|
79
|
+
readonly size?: number;
|
|
80
|
+
}>();
|
|
81
|
+
|
|
82
|
+
const emit = defineEmits<{
|
|
83
|
+
change: [number | null];
|
|
84
|
+
}>();
|
|
85
|
+
|
|
86
|
+
const disabled = useDisabled(toRef(() => componentDisabled));
|
|
87
|
+
const {id} = useFormFieldInjection();
|
|
88
|
+
|
|
89
|
+
const hoverValue = ref<number | null>(null);
|
|
90
|
+
|
|
91
|
+
const isInteractive = computed(() => !disabled.value && !isReadonly);
|
|
92
|
+
const displayValue = computed(() => hoverValue.value ?? modelValue.value ?? 0);
|
|
93
|
+
|
|
94
|
+
function fillFor(star: number): number {
|
|
95
|
+
return Math.min(1, Math.max(0, displayValue.value - (star - 1)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveStarValue(star: number, evt: MouseEvent): number {
|
|
99
|
+
if (!allowHalf) {
|
|
100
|
+
return star;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const {left, width} = (evt.currentTarget as HTMLElement).getBoundingClientRect();
|
|
104
|
+
return evt.clientX - left < width / 2 ? star - 0.5 : star;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function commit(value: number | null): void {
|
|
108
|
+
modelValue.value = value;
|
|
109
|
+
emit('change', value);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function onMouseMove(star: number, evt: MouseEvent): void {
|
|
113
|
+
if (!isInteractive.value) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
hoverValue.value = resolveStarValue(star, evt);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onClick(star: number, evt: MouseEvent): void {
|
|
121
|
+
if (!isInteractive.value) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const value = resolveStarValue(star, evt);
|
|
126
|
+
commit(clearable && modelValue.value === value ? null : value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function onKeyDown(evt: KeyboardEvent): void {
|
|
130
|
+
if (!isInteractive.value) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const step = allowHalf ? 0.5 : 1;
|
|
135
|
+
const current = modelValue.value ?? 0;
|
|
136
|
+
|
|
137
|
+
switch (evt.key) {
|
|
138
|
+
case 'ArrowRight':
|
|
139
|
+
case 'ArrowUp':
|
|
140
|
+
evt.preventDefault();
|
|
141
|
+
commit(Math.min(count, current + step));
|
|
142
|
+
break;
|
|
143
|
+
|
|
144
|
+
case 'ArrowLeft':
|
|
145
|
+
case 'ArrowDown':
|
|
146
|
+
evt.preventDefault();
|
|
147
|
+
commit(Math.max(0, current - step));
|
|
148
|
+
break;
|
|
149
|
+
|
|
150
|
+
case 'Home':
|
|
151
|
+
evt.preventDefault();
|
|
152
|
+
commit(0);
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case 'End':
|
|
156
|
+
evt.preventDefault();
|
|
157
|
+
commit(count);
|
|
158
|
+
break;
|
|
159
|
+
|
|
160
|
+
default:
|
|
161
|
+
if (/^[0-9]$/.test(evt.key)) {
|
|
162
|
+
const digit = Number(evt.key);
|
|
163
|
+
|
|
164
|
+
if (digit <= count) {
|
|
165
|
+
evt.preventDefault();
|
|
166
|
+
commit(digit);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
</script>
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Anchor
|
|
3
|
+
ref="anchor"
|
|
4
|
+
:class="clsx(
|
|
5
|
+
disabled ? $style.formTagsInputDisabled : $style.formTagsInputEnabled,
|
|
6
|
+
isCondensed && $style.isCondensed,
|
|
7
|
+
isSecondary && $style.isSecondary,
|
|
8
|
+
error && $style.isInvalid
|
|
9
|
+
)"
|
|
10
|
+
:id="id"
|
|
11
|
+
role="group"
|
|
12
|
+
tag-name="div"
|
|
13
|
+
@click="focusInput">
|
|
14
|
+
<FluxTag
|
|
15
|
+
v-for="(tag, index) of modelValue"
|
|
16
|
+
:key="`${tag}-${index}`"
|
|
17
|
+
:color="tagColor"
|
|
18
|
+
:label="tag"
|
|
19
|
+
is-deletable
|
|
20
|
+
@delete="removeAt(index)"/>
|
|
21
|
+
|
|
22
|
+
<input
|
|
23
|
+
ref="input"
|
|
24
|
+
v-model="query"
|
|
25
|
+
:class="$style.formTagsInputField"
|
|
26
|
+
:name="name"
|
|
27
|
+
autocomplete="off"
|
|
28
|
+
:disabled="disabled"
|
|
29
|
+
:placeholder="modelValue.length === 0 ? placeholder : undefined"
|
|
30
|
+
:readonly="isReadonly"
|
|
31
|
+
type="text"
|
|
32
|
+
@input="onInput"
|
|
33
|
+
@keydown="onKeyDown"
|
|
34
|
+
@paste="onPaste">
|
|
35
|
+
</Anchor>
|
|
36
|
+
|
|
37
|
+
<Teleport to="body">
|
|
38
|
+
<FluxFadeTransition>
|
|
39
|
+
<AnchorPopup
|
|
40
|
+
v-if="isOpen && filteredSuggestions.length > 0"
|
|
41
|
+
ref="popup"
|
|
42
|
+
:class="$style.formTagsInputPopup"
|
|
43
|
+
:anchor="anchorRef"
|
|
44
|
+
direction="vertical"
|
|
45
|
+
use-anchor-width>
|
|
46
|
+
<FluxMenu>
|
|
47
|
+
<FluxMenuItem
|
|
48
|
+
v-for="(suggestion, index) of filteredSuggestions"
|
|
49
|
+
:key="suggestion.value ?? index"
|
|
50
|
+
:icon-leading="suggestion.icon"
|
|
51
|
+
:is-highlighted="highlightedIndex === index"
|
|
52
|
+
:label="suggestion.label"
|
|
53
|
+
type="button"
|
|
54
|
+
@click="addSuggestion(suggestion)"/>
|
|
55
|
+
</FluxMenu>
|
|
56
|
+
</AnchorPopup>
|
|
57
|
+
</FluxFadeTransition>
|
|
58
|
+
</Teleport>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<script
|
|
62
|
+
lang="ts"
|
|
63
|
+
setup>
|
|
64
|
+
import { useClickOutside } from '@basmilius/common';
|
|
65
|
+
import type { FluxColor, FluxFormInputBaseProps, FluxFormSelectOption } from '@flux-ui/types';
|
|
66
|
+
import { clsx } from 'clsx';
|
|
67
|
+
import { type ComponentPublicInstance, computed, nextTick, ref, toRef, useTemplateRef } from 'vue';
|
|
68
|
+
import { useDisabled, useFormFieldInjection } from '~flux/components/composable';
|
|
69
|
+
import { FluxFadeTransition } from '~flux/components/transition';
|
|
70
|
+
import { Anchor, AnchorPopup } from '~flux/components/component/primitive';
|
|
71
|
+
import FluxMenu from './FluxMenu.vue';
|
|
72
|
+
import FluxMenuItem from './FluxMenuItem.vue';
|
|
73
|
+
import FluxTag from './FluxTag.vue';
|
|
74
|
+
import $style from '~flux/components/css/component/Form.module.scss';
|
|
75
|
+
|
|
76
|
+
const modelValue = defineModel<string[]>({
|
|
77
|
+
default: () => []
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const query = defineModel<string>('searchQuery', {
|
|
81
|
+
default: ''
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const {
|
|
85
|
+
allowDuplicates,
|
|
86
|
+
delimiters = ['Enter', ','],
|
|
87
|
+
disabled: componentDisabled,
|
|
88
|
+
max,
|
|
89
|
+
suggestions,
|
|
90
|
+
tagColor,
|
|
91
|
+
validate
|
|
92
|
+
} = defineProps<Pick<FluxFormInputBaseProps, 'disabled' | 'error' | 'isCondensed' | 'isReadonly' | 'isSecondary' | 'name' | 'placeholder'> & {
|
|
93
|
+
readonly allowDuplicates?: boolean;
|
|
94
|
+
readonly delimiters?: string[];
|
|
95
|
+
readonly max?: number;
|
|
96
|
+
readonly suggestions?: FluxFormSelectOption[];
|
|
97
|
+
readonly tagColor?: FluxColor;
|
|
98
|
+
readonly validate?: (value: string) => boolean;
|
|
99
|
+
}>();
|
|
100
|
+
|
|
101
|
+
const emit = defineEmits<{
|
|
102
|
+
add: [string];
|
|
103
|
+
remove: [string];
|
|
104
|
+
}>();
|
|
105
|
+
|
|
106
|
+
const disabled = useDisabled(toRef(() => componentDisabled));
|
|
107
|
+
const {id} = useFormFieldInjection();
|
|
108
|
+
|
|
109
|
+
const anchorRef = useTemplateRef<ComponentPublicInstance>('anchor');
|
|
110
|
+
const popupRef = useTemplateRef<ComponentPublicInstance>('popup');
|
|
111
|
+
const inputElementRef = useTemplateRef<HTMLInputElement>('input');
|
|
112
|
+
|
|
113
|
+
const isOpen = ref(false);
|
|
114
|
+
const highlightedIndex = ref(-1);
|
|
115
|
+
|
|
116
|
+
const isMaxReached = computed(() => max !== undefined && modelValue.value.length >= max);
|
|
117
|
+
const filteredSuggestions = computed(() => {
|
|
118
|
+
if (!suggestions) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const search = query.value.trim().toLowerCase();
|
|
123
|
+
|
|
124
|
+
return suggestions.filter(suggestion =>
|
|
125
|
+
!modelValue.value.includes(suggestion.label) &&
|
|
126
|
+
(search === '' || suggestion.label.toLowerCase().includes(search)));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function focusInput(): void {
|
|
130
|
+
inputElementRef.value?.focus();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function addTag(rawValue: string): void {
|
|
134
|
+
const value = rawValue.trim();
|
|
135
|
+
|
|
136
|
+
if (value === '' || isMaxReached.value) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!allowDuplicates && modelValue.value.includes(value)) {
|
|
141
|
+
query.value = '';
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (validate && !validate(value)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
modelValue.value = [...modelValue.value, value];
|
|
150
|
+
emit('add', value);
|
|
151
|
+
|
|
152
|
+
query.value = '';
|
|
153
|
+
highlightedIndex.value = -1;
|
|
154
|
+
isOpen.value = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function removeAt(index: number): void {
|
|
158
|
+
const removed = modelValue.value[index];
|
|
159
|
+
modelValue.value = modelValue.value.filter((_, i) => i !== index);
|
|
160
|
+
emit('remove', removed);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function addSuggestion(suggestion: FluxFormSelectOption): void {
|
|
164
|
+
addTag(suggestion.label);
|
|
165
|
+
nextTick(() => focusInput());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function onInput(): void {
|
|
169
|
+
isOpen.value = query.value.trim() !== '' && filteredSuggestions.value.length > 0;
|
|
170
|
+
highlightedIndex.value = -1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function onKeyDown(evt: KeyboardEvent): void {
|
|
174
|
+
if (evt.key === 'Escape' && isOpen.value) {
|
|
175
|
+
evt.preventDefault();
|
|
176
|
+
isOpen.value = false;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isOpen.value && (evt.key === 'ArrowDown' || evt.key === 'ArrowUp')) {
|
|
181
|
+
evt.preventDefault();
|
|
182
|
+
|
|
183
|
+
const count = filteredSuggestions.value.length;
|
|
184
|
+
|
|
185
|
+
if (count === 0) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
highlightedIndex.value = evt.key === 'ArrowDown'
|
|
190
|
+
? (highlightedIndex.value + 1) % count
|
|
191
|
+
: (highlightedIndex.value - 1 + count) % count;
|
|
192
|
+
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (evt.key === 'Enter') {
|
|
197
|
+
if (isOpen.value && highlightedIndex.value >= 0) {
|
|
198
|
+
evt.preventDefault();
|
|
199
|
+
addSuggestion(filteredSuggestions.value[highlightedIndex.value]);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (delimiters.includes('Enter')) {
|
|
204
|
+
evt.preventDefault();
|
|
205
|
+
addTag(query.value);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (evt.key.length === 1 && delimiters.includes(evt.key)) {
|
|
212
|
+
evt.preventDefault();
|
|
213
|
+
addTag(query.value);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (evt.key === 'Backspace' && query.value === '' && modelValue.value.length > 0) {
|
|
218
|
+
removeAt(modelValue.value.length - 1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function onPaste(evt: ClipboardEvent): void {
|
|
223
|
+
if (!evt.clipboardData) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const separators = [...delimiters.filter(delimiter => delimiter !== 'Enter'), '\n'];
|
|
228
|
+
const text = evt.clipboardData.getData('text');
|
|
229
|
+
|
|
230
|
+
let parts = [text];
|
|
231
|
+
|
|
232
|
+
for (const separator of separators) {
|
|
233
|
+
parts = parts.flatMap(part => part.split(separator));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
parts = parts.map(part => part.trim()).filter(part => part !== '');
|
|
237
|
+
|
|
238
|
+
if (parts.length <= 1) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
evt.preventDefault();
|
|
243
|
+
parts.forEach(addTag);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (typeof window !== 'undefined') {
|
|
247
|
+
useClickOutside([anchorRef, popupRef], isOpen, () => isOpen.value = false);
|
|
248
|
+
}
|
|
249
|
+
</script>
|
|
@@ -29,9 +29,10 @@
|
|
|
29
29
|
<script
|
|
30
30
|
lang="ts"
|
|
31
31
|
setup>
|
|
32
|
+
import { unrefTemplateElement } from '@flux-ui/internals';
|
|
32
33
|
import type { FluxAutoCompleteType, FluxFormInputBaseProps } from '@flux-ui/types';
|
|
33
34
|
import { clsx } from 'clsx';
|
|
34
|
-
import { toRef } from 'vue';
|
|
35
|
+
import { toRef, useTemplateRef } from 'vue';
|
|
35
36
|
import { useDisabled, useFormFieldInjection } from '~flux/components/composable';
|
|
36
37
|
import $style from '~flux/components/css/component/Form.module.scss';
|
|
37
38
|
|
|
@@ -55,5 +56,19 @@
|
|
|
55
56
|
}>();
|
|
56
57
|
|
|
57
58
|
const disabled = useDisabled(toRef(() => componentDisabled));
|
|
59
|
+
const inputRef = useTemplateRef<HTMLTextAreaElement>('input');
|
|
58
60
|
const {id} = useFormFieldInjection();
|
|
61
|
+
|
|
62
|
+
function blur(): void {
|
|
63
|
+
unrefTemplateElement(inputRef)?.blur();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function focus(): void {
|
|
67
|
+
unrefTemplateElement(inputRef)?.focus();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
defineExpose({
|
|
71
|
+
blur,
|
|
72
|
+
focus
|
|
73
|
+
});
|
|
59
74
|
</script>
|