inertia-bootstrap-forms 2.1.21 → 3.0.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.
- package/README.fa.md +468 -0
- package/README.md +322 -714
- package/dist/inertia-bootstrap-forms.cjs.js +1 -0
- package/dist/inertia-bootstrap-forms.es.js +3524 -30
- package/index.d.ts +143 -507
- package/package.json +89 -70
- package/src/AmountInput.vue +81 -76
- package/src/CaptchaInput.vue +87 -88
- package/src/CheckboxButtonInput.vue +56 -69
- package/src/CheckboxInput.vue +60 -79
- package/src/CheckboxToggle.vue +53 -136
- package/src/ChipsInput.vue +157 -0
- package/src/ComboboxInput.vue +556 -0
- package/src/DropzoneInput.vue +125 -103
- package/src/EditorInput.vue +47 -106
- package/src/EmailInput.vue +16 -15
- package/src/FileInput.vue +189 -179
- package/src/FormAdornInput.vue +75 -0
- package/src/FormContainer.vue +288 -251
- package/src/FormField.vue +59 -0
- package/src/FormLabel.vue +27 -27
- package/src/GroupControl.vue +55 -53
- package/src/LocationInput.vue +79 -72
- package/src/MobileInput.vue +21 -13
- package/src/MultiQuantityInput.vue +109 -181
- package/src/OTPInput.vue +144 -76
- package/src/PasswordInput.vue +151 -17
- package/src/PersianDatePickerInput.vue +63 -166
- package/src/QuantityInput.vue +100 -119
- package/src/RangeInput.vue +96 -0
- package/src/SimpleUploader.vue +8 -143
- package/src/StarRatingInput.vue +114 -138
- package/src/SubmitButton.vue +39 -24
- package/src/SwitchInput.vue +58 -83
- package/src/TelInput.vue +86 -120
- package/src/TextAreaInput.vue +29 -33
- package/src/TextInput.vue +42 -51
- package/src/UppyInput.vue +237 -324
- package/src/composables/useBsInstance.js +66 -0
- package/src/composables/useChoice.js +34 -0
- package/src/composables/useDisposables.js +59 -0
- package/src/composables/useField.js +121 -0
- package/src/composables/useFormApi.js +12 -0
- package/src/i18n.js +120 -0
- package/src/index.js +123 -42
- package/src/utils/bootstrap.js +48 -0
- package/src/utils/digits.js +21 -0
- package/src/utils/http.js +15 -0
- package/dist/choices-KiM-I_0P.js +0 -2955
- package/dist/dropdown-yh2aQrbw-XuCU1v1F.js +0 -1543
- package/dist/index-BRtKc2RA.js +0 -604
- package/dist/index-_x4UqcXz.js +0 -4938
- package/dist/inertia-bootstrap-forms.umd.js +0 -19
- package/dist/range-slider-pips-DTDOg3Q9.js +0 -3506
- package/dist/style.css +0 -1
- package/src/css/from-select.scss +0 -384
- package/src/css/loader.gif +0 -0
- package/src/css/location.scss +0 -44
- package/src/css/simple-upload.scss +0 -54
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { computed, defineComponent, nextTick, reactive, ref, watch } from 'vue';
|
|
3
|
+
import { fieldProps, useField } from './composables/useField.js';
|
|
4
|
+
import { useBsInstance } from './composables/useBsInstance.js';
|
|
5
|
+
import { useDisposables } from './composables/useDisposables.js';
|
|
6
|
+
import { useI18n } from './i18n.js';
|
|
7
|
+
|
|
8
|
+
const isObject = (v) => v !== null && typeof v === 'object';
|
|
9
|
+
const isEmpty = (v) => v === null || v === undefined || v === '';
|
|
10
|
+
|
|
11
|
+
function readXsrf() {
|
|
12
|
+
if (typeof document === 'undefined') return null;
|
|
13
|
+
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
|
|
14
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bootstrap 6 Combobox (replaces the former choices.js based Select2Input).
|
|
19
|
+
*
|
|
20
|
+
* - single / multiple selection, client side search or remote (AJAX) search
|
|
21
|
+
* - the value lives in the FormContainer's v-model, like every other field
|
|
22
|
+
* - slots: #option (menu items) and #selection (value shown in the toggle)
|
|
23
|
+
*/
|
|
24
|
+
export default defineComponent({
|
|
25
|
+
name: 'ComboboxInput',
|
|
26
|
+
emits: [
|
|
27
|
+
'update:modelValue',
|
|
28
|
+
'change',
|
|
29
|
+
'selected',
|
|
30
|
+
'clear',
|
|
31
|
+
'search',
|
|
32
|
+
'search-success',
|
|
33
|
+
'search-error',
|
|
34
|
+
'show',
|
|
35
|
+
'shown',
|
|
36
|
+
'hide',
|
|
37
|
+
'hidden',
|
|
38
|
+
],
|
|
39
|
+
props: {
|
|
40
|
+
...fieldProps,
|
|
41
|
+
/** Strings, numbers or objects ({ id | value, name | label, description, disabled }). */
|
|
42
|
+
options: { type: Array, default: () => [] },
|
|
43
|
+
/** Property that holds the value of an option object (default: id → value → name → label). */
|
|
44
|
+
valueKey: { type: String, default: null },
|
|
45
|
+
/** Property that holds the label of an option object (default: name → label → title → value). */
|
|
46
|
+
labelKey: { type: String, default: null },
|
|
47
|
+
/** Legacy alias of `labelKey`. */
|
|
48
|
+
label: { type: String, default: null },
|
|
49
|
+
descriptionKey: { type: String, default: 'description' },
|
|
50
|
+
multiple: { type: Boolean, default: false },
|
|
51
|
+
placeholder: { type: String, default: null },
|
|
52
|
+
/** Show a client-side search box. */
|
|
53
|
+
searchEnabled: { type: Boolean, default: false },
|
|
54
|
+
searchPlaceholder: { type: String, default: null },
|
|
55
|
+
/**
|
|
56
|
+
* Remote search: { url, method = 'POST', param = 'query', debounce = 400, minChars = 0, headers = {}, resultsKey }.
|
|
57
|
+
* The endpoint must answer with a JSON array (or { data: [] }).
|
|
58
|
+
*/
|
|
59
|
+
search: { type: Object, default: () => ({ url: null }) },
|
|
60
|
+
/** Add an entry at the top of a single select that clears the value. */
|
|
61
|
+
clearable: { type: Boolean, default: false },
|
|
62
|
+
clearText: { type: String, default: null },
|
|
63
|
+
noResultsText: { type: String, default: null },
|
|
64
|
+
loadingText: { type: String, default: null },
|
|
65
|
+
/** Text of a multiple select when more than one item is chosen. `{count}` is replaced. */
|
|
66
|
+
selectedText: { type: String, default: null },
|
|
67
|
+
/**
|
|
68
|
+
* Multiple select only: show the chosen options themselves (true) instead of the
|
|
69
|
+
* "N selected" counter (false, default).
|
|
70
|
+
*/
|
|
71
|
+
showSelected: { type: Boolean, default: true },
|
|
72
|
+
/** With `showSelected`: above this many chosen options the counter is shown instead. */
|
|
73
|
+
maxVisible: { type: Number, default: null },
|
|
74
|
+
/** Separator between the chosen labels (default: "، " for fa, ", " otherwise). */
|
|
75
|
+
selectedSeparator: { type: String, default: null },
|
|
76
|
+
searchNormalize: { type: Boolean, default: false },
|
|
77
|
+
/** Floating UI placement of the menu. */
|
|
78
|
+
placement: { type: String, default: 'bottom-start' },
|
|
79
|
+
size: { type: String, default: null },
|
|
80
|
+
/** Hide the caret icon. */
|
|
81
|
+
hideDropdown: { type: Boolean, default: false },
|
|
82
|
+
locale: { type: String, default: null },
|
|
83
|
+
},
|
|
84
|
+
setup(props, { emit, expose, slots }) {
|
|
85
|
+
const field = useField(props, emit);
|
|
86
|
+
const { t, locale } = useI18n(() => props.locale);
|
|
87
|
+
const d = useDisposables();
|
|
88
|
+
|
|
89
|
+
const root = ref(null);
|
|
90
|
+
const toggle = ref(null);
|
|
91
|
+
const menu = ref(null);
|
|
92
|
+
const valueEl = ref(null);
|
|
93
|
+
const searchInput = ref(null);
|
|
94
|
+
|
|
95
|
+
const known = ref({}); // key -> option (every option ever seen, so labels survive remote searches)
|
|
96
|
+
const remote = reactive({ results: null, loading: false, error: false });
|
|
97
|
+
|
|
98
|
+
const remoteUrl = computed(() => props.search?.url || null);
|
|
99
|
+
const hasSearch = computed(() => props.searchEnabled || !!remoteUrl.value);
|
|
100
|
+
|
|
101
|
+
// ---------- option helpers ----------
|
|
102
|
+
const valueOf = (item) => {
|
|
103
|
+
if (!isObject(item)) return item;
|
|
104
|
+
if (props.valueKey) return item[props.valueKey];
|
|
105
|
+
return item.id ?? item.value ?? item.name ?? item.label;
|
|
106
|
+
};
|
|
107
|
+
const labelOf = (item) => {
|
|
108
|
+
if (!isObject(item)) return item;
|
|
109
|
+
const key = props.labelKey || props.label;
|
|
110
|
+
return (key ? item[key] : (item.name ?? item.label ?? item.title ?? item.value)) ?? valueOf(item);
|
|
111
|
+
};
|
|
112
|
+
const descriptionOf = (item) => (isObject(item) ? item[props.descriptionKey] ?? null : null);
|
|
113
|
+
const keyOf = (item) => String(valueOf(item));
|
|
114
|
+
|
|
115
|
+
function register(list) {
|
|
116
|
+
const next = { ...known.value };
|
|
117
|
+
(list || []).forEach((o) => {
|
|
118
|
+
next[keyOf(o)] = o;
|
|
119
|
+
});
|
|
120
|
+
known.value = next;
|
|
121
|
+
}
|
|
122
|
+
watch(() => props.options, (list) => register(list), { immediate: true, deep: true });
|
|
123
|
+
|
|
124
|
+
// ---------- selection ----------
|
|
125
|
+
const selectedRaw = computed(() => {
|
|
126
|
+
const v = field.model.value;
|
|
127
|
+
if (props.multiple) {
|
|
128
|
+
if (Array.isArray(v)) return v;
|
|
129
|
+
if (isObject(v)) return Object.values(v);
|
|
130
|
+
return isEmpty(v) ? [] : [v];
|
|
131
|
+
}
|
|
132
|
+
return isEmpty(v) ? [] : [v];
|
|
133
|
+
});
|
|
134
|
+
const selectedKeys = computed(() => selectedRaw.value.map((v) => (isObject(v) ? keyOf(v) : String(v))));
|
|
135
|
+
|
|
136
|
+
const labelForKey = (key) => {
|
|
137
|
+
const option = known.value[key];
|
|
138
|
+
return option !== undefined ? String(labelOf(option)) : key;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const selectedItems = computed(() =>
|
|
142
|
+
selectedKeys.value.map((key) => {
|
|
143
|
+
const option = known.value[key];
|
|
144
|
+
return {
|
|
145
|
+
key,
|
|
146
|
+
raw: option,
|
|
147
|
+
label: option !== undefined ? String(labelOf(option)) : key,
|
|
148
|
+
description: option !== undefined ? descriptionOf(option) : null,
|
|
149
|
+
};
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const separator = computed(() => props.selectedSeparator ?? (locale.value === 'fa' ? '، ' : ', '));
|
|
154
|
+
|
|
155
|
+
// multiple + several chosen: list the options (showSelected) or show the counter
|
|
156
|
+
const listsSelected = computed(
|
|
157
|
+
() =>
|
|
158
|
+
props.multiple &&
|
|
159
|
+
props.showSelected &&
|
|
160
|
+
selectedKeys.value.length > 1 &&
|
|
161
|
+
(props.maxVisible === null || selectedKeys.value.length <= props.maxVisible)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// the #selection slot renders single values and the listed options of a multiple select
|
|
165
|
+
const customSelection = computed(
|
|
166
|
+
() => !!slots.selection && selectedKeys.value.length > 0 && (!props.multiple || selectedKeys.value.length === 1 || listsSelected.value)
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const displayText = computed(() => {
|
|
170
|
+
const keys = selectedKeys.value;
|
|
171
|
+
if (!keys.length) return props.placeholder ?? t('select.placeholder');
|
|
172
|
+
|
|
173
|
+
if (props.multiple && keys.length > 1) {
|
|
174
|
+
if (listsSelected.value) return selectedItems.value.map((i) => i.label).join(separator.value);
|
|
175
|
+
return (props.selectedText ?? t('select.selectedCount')).replace('{count}', keys.length);
|
|
176
|
+
}
|
|
177
|
+
return labelForKey(keys[0]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// text that lives in `.combobox-value` (empty while the #selection slot is drawing the value)
|
|
181
|
+
const plainText = computed(() => (customSelection.value ? '' : displayText.value));
|
|
182
|
+
const showingPlaceholder = computed(() => selectedKeys.value.length === 0);
|
|
183
|
+
|
|
184
|
+
// ---------- rendered options ----------
|
|
185
|
+
const rendered = computed(() => {
|
|
186
|
+
let list = remote.results ?? props.options;
|
|
187
|
+
|
|
188
|
+
if (remote.results && props.multiple) {
|
|
189
|
+
// keep the already selected items visible while searching
|
|
190
|
+
const present = new Set(list.map(keyOf));
|
|
191
|
+
const extra = selectedKeys.value.filter((k) => !present.has(k) && known.value[k] !== undefined).map((k) => known.value[k]);
|
|
192
|
+
list = [...extra, ...list];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return list.map((item) => ({
|
|
196
|
+
raw: item,
|
|
197
|
+
key: keyOf(item),
|
|
198
|
+
label: labelOf(item),
|
|
199
|
+
description: descriptionOf(item),
|
|
200
|
+
disabled: isObject(item) && !!item.disabled,
|
|
201
|
+
}));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------- DOM sync (Bootstrap owns classes of the items, Vue owns their existence) ----------
|
|
205
|
+
function syncSelection() {
|
|
206
|
+
if (!menu.value) return;
|
|
207
|
+
const selected = new Set(selectedKeys.value);
|
|
208
|
+
menu.value.querySelectorAll('.menu-item[data-bs-value]').forEach((el) => {
|
|
209
|
+
const on = selected.has(el.dataset.bsValue) && el.dataset.bsValue !== '';
|
|
210
|
+
el.classList.toggle('selected', on);
|
|
211
|
+
el.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function syncDisplay() {
|
|
216
|
+
if (!valueEl.value) return;
|
|
217
|
+
valueEl.value.textContent = plainText.value;
|
|
218
|
+
valueEl.value.classList.toggle('combobox-placeholder', showingPlaceholder.value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function syncInvalid() {
|
|
222
|
+
toggle.value?.classList.toggle('is-invalid', field.isInvalid.value);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sync() {
|
|
226
|
+
syncSelection();
|
|
227
|
+
syncDisplay();
|
|
228
|
+
syncInvalid();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
watch([() => field.model.value, rendered, plainText, field.isInvalid], () => nextTick(sync), { flush: 'post' });
|
|
232
|
+
|
|
233
|
+
// ---------- Bootstrap events ----------
|
|
234
|
+
function toRaw(key) {
|
|
235
|
+
const option = known.value[key];
|
|
236
|
+
return option !== undefined ? valueOf(option) : key;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function onBsChange(event) {
|
|
240
|
+
let next;
|
|
241
|
+
let chosen;
|
|
242
|
+
|
|
243
|
+
if (props.multiple) {
|
|
244
|
+
const keys = event.value || [];
|
|
245
|
+
next = keys.map(toRaw);
|
|
246
|
+
chosen = keys.map((k) => known.value[k]);
|
|
247
|
+
} else if (event.value === '' || event.value === undefined) {
|
|
248
|
+
next = null;
|
|
249
|
+
chosen = null;
|
|
250
|
+
} else {
|
|
251
|
+
next = toRaw(event.value);
|
|
252
|
+
chosen = known.value[event.value];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
field.model.value = next;
|
|
256
|
+
if (next === null) emit('clear');
|
|
257
|
+
emit('selected', chosen);
|
|
258
|
+
field.emitChange(next, event);
|
|
259
|
+
|
|
260
|
+
// Bootstrap just rewrote the label in English; restore the real one
|
|
261
|
+
nextTick(sync);
|
|
262
|
+
syncDisplay();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------- remote search ----------
|
|
266
|
+
let timer = null;
|
|
267
|
+
let controller = null;
|
|
268
|
+
d.add(() => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
controller?.abort();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
function resetClientFilter() {
|
|
274
|
+
if (!menu.value) return;
|
|
275
|
+
menu.value.querySelectorAll('.menu-item[data-bs-value]').forEach((el) => {
|
|
276
|
+
el.style.display = '';
|
|
277
|
+
});
|
|
278
|
+
menu.value.querySelector('.combobox-no-results')?.classList.toggle('d-none', rendered.value.length > 0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runSearch(term) {
|
|
282
|
+
const cfg = props.search || {};
|
|
283
|
+
if (!cfg.url) return;
|
|
284
|
+
|
|
285
|
+
controller?.abort();
|
|
286
|
+
controller = new AbortController();
|
|
287
|
+
remote.loading = true;
|
|
288
|
+
remote.error = false;
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const url = new URL(cfg.url, window.location.origin);
|
|
292
|
+
url.searchParams.set(cfg.param || 'query', term);
|
|
293
|
+
|
|
294
|
+
const headers = {
|
|
295
|
+
Accept: 'application/json',
|
|
296
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
297
|
+
...(cfg.headers || {}),
|
|
298
|
+
};
|
|
299
|
+
const xsrf = readXsrf();
|
|
300
|
+
if (xsrf && !headers['X-XSRF-TOKEN']) headers['X-XSRF-TOKEN'] = xsrf;
|
|
301
|
+
|
|
302
|
+
const response = await fetch(url, {
|
|
303
|
+
method: (cfg.method || 'POST').toUpperCase(),
|
|
304
|
+
headers,
|
|
305
|
+
credentials: 'same-origin',
|
|
306
|
+
signal: controller.signal,
|
|
307
|
+
});
|
|
308
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
309
|
+
|
|
310
|
+
const json = await response.json();
|
|
311
|
+
const list = Array.isArray(json) ? json : json?.[cfg.resultsKey || 'data'] ?? json?.results ?? [];
|
|
312
|
+
|
|
313
|
+
register(list);
|
|
314
|
+
remote.results = list;
|
|
315
|
+
remote.loading = false;
|
|
316
|
+
emit('search-success', list);
|
|
317
|
+
|
|
318
|
+
await nextTick();
|
|
319
|
+
resetClientFilter();
|
|
320
|
+
sync();
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error?.name === 'AbortError') return;
|
|
323
|
+
remote.loading = false;
|
|
324
|
+
remote.error = true;
|
|
325
|
+
remote.results = [];
|
|
326
|
+
emit('search-error', error);
|
|
327
|
+
await nextTick();
|
|
328
|
+
resetClientFilter();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function onSearchInput(event) {
|
|
333
|
+
const term = event.target.value;
|
|
334
|
+
emit('search', term);
|
|
335
|
+
|
|
336
|
+
if (!remoteUrl.value) return;
|
|
337
|
+
clearTimeout(timer);
|
|
338
|
+
|
|
339
|
+
const minChars = props.search?.minChars ?? 0;
|
|
340
|
+
if (term.length < minChars) {
|
|
341
|
+
controller?.abort();
|
|
342
|
+
remote.results = null;
|
|
343
|
+
remote.loading = false;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
remote.loading = true;
|
|
348
|
+
timer = setTimeout(() => runSearch(term), props.search?.debounce ?? 400);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---------- Bootstrap instance ----------
|
|
352
|
+
let offs = [];
|
|
353
|
+
const { instance, create } = useBsInstance(
|
|
354
|
+
'Combobox',
|
|
355
|
+
() => toggle.value,
|
|
356
|
+
() => ({
|
|
357
|
+
multiple: props.multiple,
|
|
358
|
+
placeholder: props.placeholder ?? t('select.placeholder'),
|
|
359
|
+
placement: props.placement,
|
|
360
|
+
search: hasSearch.value,
|
|
361
|
+
searchNormalize: props.searchNormalize,
|
|
362
|
+
}),
|
|
363
|
+
{
|
|
364
|
+
onCreate(inst, el) {
|
|
365
|
+
const on = (target, type, fn) => {
|
|
366
|
+
target.addEventListener(type, fn);
|
|
367
|
+
offs.push(() => target.removeEventListener(type, fn));
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
on(el, 'change.bs.combobox', onBsChange);
|
|
371
|
+
on(el, 'show.bs.combobox', (e) => emit('show', e));
|
|
372
|
+
on(el, 'shown.bs.combobox', (e) => emit('shown', e));
|
|
373
|
+
on(el, 'hide.bs.combobox', (e) => emit('hide', e));
|
|
374
|
+
on(el, 'hidden.bs.combobox', (e) => {
|
|
375
|
+
if (remote.results !== null) {
|
|
376
|
+
remote.results = null; // back to the initial options
|
|
377
|
+
remote.error = false;
|
|
378
|
+
nextTick(() => {
|
|
379
|
+
resetClientFilter();
|
|
380
|
+
sync();
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
emit('hidden', e);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
if (searchInput.value) {
|
|
387
|
+
on(searchInput.value, 'input', onSearchInput);
|
|
388
|
+
on(searchInput.value, 'keydown', (e) => e.key === 'Enter' && e.preventDefault());
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
sync();
|
|
392
|
+
},
|
|
393
|
+
onDestroy() {
|
|
394
|
+
offs.forEach((off) => off());
|
|
395
|
+
offs = [];
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Options that change what the plugin reads at construction time
|
|
401
|
+
watch(
|
|
402
|
+
() => [props.multiple, hasSearch.value, props.placement, props.searchNormalize],
|
|
403
|
+
async () => {
|
|
404
|
+
await nextTick();
|
|
405
|
+
create();
|
|
406
|
+
}
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
expose({
|
|
410
|
+
show: () => instance.value?.show(),
|
|
411
|
+
hide: () => instance.value?.hide(),
|
|
412
|
+
toggle: () => instance.value?.toggle(),
|
|
413
|
+
clear: () => {
|
|
414
|
+
field.model.value = props.multiple ? [] : null;
|
|
415
|
+
emit('clear');
|
|
416
|
+
field.emitChange(field.model.value);
|
|
417
|
+
},
|
|
418
|
+
focus: () => toggle.value?.focus(),
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
...field,
|
|
423
|
+
t,
|
|
424
|
+
root,
|
|
425
|
+
toggle,
|
|
426
|
+
menu,
|
|
427
|
+
valueEl,
|
|
428
|
+
searchInput,
|
|
429
|
+
remote,
|
|
430
|
+
hasSearch,
|
|
431
|
+
rendered,
|
|
432
|
+
displayText,
|
|
433
|
+
plainText,
|
|
434
|
+
selectedItems,
|
|
435
|
+
customSelection,
|
|
436
|
+
separator,
|
|
437
|
+
selectedSet: computed(() => new Set(selectedKeys.value)),
|
|
438
|
+
showingPlaceholder,
|
|
439
|
+
sizeClass: computed(() => (props.size ? `form-control-${props.size}` : null)),
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
</script>
|
|
444
|
+
|
|
445
|
+
<template>
|
|
446
|
+
<div ref="root" class="combobox">
|
|
447
|
+
<button
|
|
448
|
+
ref="toggle"
|
|
449
|
+
type="button"
|
|
450
|
+
class="combobox-toggle form-control"
|
|
451
|
+
:class="sizeClass"
|
|
452
|
+
data-bs-toggle="combobox"
|
|
453
|
+
:id="inputId"
|
|
454
|
+
:disabled="isDisabled || readonly"
|
|
455
|
+
aria-haspopup="listbox"
|
|
456
|
+
:aria-invalid="isInvalid || undefined"
|
|
457
|
+
:aria-required="required || undefined"
|
|
458
|
+
>
|
|
459
|
+
<span v-if="customSelection" class="combobox-selection d-flex align-items-center flex-wrap gap-2 text-start">
|
|
460
|
+
<template v-for="(item, index) in selectedItems" :key="item.key">
|
|
461
|
+
<slot
|
|
462
|
+
name="selection"
|
|
463
|
+
:option="item.raw"
|
|
464
|
+
:label="item.label"
|
|
465
|
+
:description="item.description"
|
|
466
|
+
:value="item.key"
|
|
467
|
+
:index="index"
|
|
468
|
+
:multiple="multiple"
|
|
469
|
+
>{{ item.label }}</slot>
|
|
470
|
+
</template>
|
|
471
|
+
</span>
|
|
472
|
+
<span ref="valueEl" class="combobox-value" :class="{ 'combobox-placeholder': showingPlaceholder }" v-text="plainText"></span>
|
|
473
|
+
<svg
|
|
474
|
+
v-if="!hideDropdown"
|
|
475
|
+
class="combobox-caret"
|
|
476
|
+
width="16"
|
|
477
|
+
height="16"
|
|
478
|
+
viewBox="0 0 16 16"
|
|
479
|
+
fill="none"
|
|
480
|
+
stroke="currentColor"
|
|
481
|
+
stroke-width="1.5"
|
|
482
|
+
stroke-linecap="round"
|
|
483
|
+
stroke-linejoin="round"
|
|
484
|
+
aria-hidden="true"
|
|
485
|
+
>
|
|
486
|
+
<path d="m3 6 5 5 5-5" />
|
|
487
|
+
</svg>
|
|
488
|
+
</button>
|
|
489
|
+
|
|
490
|
+
<div ref="menu" class="menu" role="listbox" :aria-multiselectable="multiple || undefined">
|
|
491
|
+
<div v-if="hasSearch" class="combobox-search">
|
|
492
|
+
<input
|
|
493
|
+
ref="searchInput"
|
|
494
|
+
type="search"
|
|
495
|
+
class="form-control combobox-search-input"
|
|
496
|
+
autocomplete="off"
|
|
497
|
+
:placeholder="searchPlaceholder ?? t('select.search')"
|
|
498
|
+
/>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<div v-if="remote.loading" class="menu-header" role="status">{{ loadingText ?? t('select.loading') }}</div>
|
|
502
|
+
<div v-else-if="remote.error" class="menu-header" role="alert">{{ t('select.error') }}</div>
|
|
503
|
+
|
|
504
|
+
<button
|
|
505
|
+
v-if="clearable && !multiple"
|
|
506
|
+
type="button"
|
|
507
|
+
class="menu-item"
|
|
508
|
+
data-bs-value=""
|
|
509
|
+
role="option"
|
|
510
|
+
>
|
|
511
|
+
<span class="menu-item-content"><span>{{ clearText ?? t('select.clear') }}</span></span>
|
|
512
|
+
</button>
|
|
513
|
+
|
|
514
|
+
<button
|
|
515
|
+
v-for="item in rendered"
|
|
516
|
+
:key="item.key"
|
|
517
|
+
type="button"
|
|
518
|
+
class="menu-item"
|
|
519
|
+
role="option"
|
|
520
|
+
:data-bs-value="item.key"
|
|
521
|
+
:disabled="item.disabled || undefined"
|
|
522
|
+
>
|
|
523
|
+
<span class="menu-item-content">
|
|
524
|
+
<slot
|
|
525
|
+
name="option"
|
|
526
|
+
:option="item.raw"
|
|
527
|
+
:label="item.label"
|
|
528
|
+
:description="item.description"
|
|
529
|
+
:value="item.key"
|
|
530
|
+
:selected="selectedSet.has(item.key)"
|
|
531
|
+
:disabled="item.disabled"
|
|
532
|
+
>
|
|
533
|
+
<span>{{ item.label }}</span>
|
|
534
|
+
<span v-if="item.description" class="menu-item-description">{{ item.description }}</span>
|
|
535
|
+
</slot>
|
|
536
|
+
</span>
|
|
537
|
+
<svg
|
|
538
|
+
class="menu-item-check"
|
|
539
|
+
width="16"
|
|
540
|
+
height="16"
|
|
541
|
+
viewBox="0 0 16 16"
|
|
542
|
+
fill="none"
|
|
543
|
+
stroke="currentColor"
|
|
544
|
+
stroke-width="2"
|
|
545
|
+
stroke-linecap="round"
|
|
546
|
+
stroke-linejoin="round"
|
|
547
|
+
aria-hidden="true"
|
|
548
|
+
>
|
|
549
|
+
<path d="m3.5 8.5 3 3 6-7" />
|
|
550
|
+
</svg>
|
|
551
|
+
</button>
|
|
552
|
+
|
|
553
|
+
<div class="combobox-no-results d-none">{{ noResultsText ?? t('select.noResults') }}</div>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
</template>
|