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.
Files changed (59) hide show
  1. package/README.fa.md +468 -0
  2. package/README.md +322 -714
  3. package/dist/inertia-bootstrap-forms.cjs.js +1 -0
  4. package/dist/inertia-bootstrap-forms.es.js +3524 -30
  5. package/index.d.ts +143 -507
  6. package/package.json +89 -70
  7. package/src/AmountInput.vue +81 -76
  8. package/src/CaptchaInput.vue +87 -88
  9. package/src/CheckboxButtonInput.vue +56 -69
  10. package/src/CheckboxInput.vue +60 -79
  11. package/src/CheckboxToggle.vue +53 -136
  12. package/src/ChipsInput.vue +157 -0
  13. package/src/ComboboxInput.vue +556 -0
  14. package/src/DropzoneInput.vue +125 -103
  15. package/src/EditorInput.vue +47 -106
  16. package/src/EmailInput.vue +16 -15
  17. package/src/FileInput.vue +189 -179
  18. package/src/FormAdornInput.vue +75 -0
  19. package/src/FormContainer.vue +288 -251
  20. package/src/FormField.vue +59 -0
  21. package/src/FormLabel.vue +27 -27
  22. package/src/GroupControl.vue +55 -53
  23. package/src/LocationInput.vue +79 -72
  24. package/src/MobileInput.vue +21 -13
  25. package/src/MultiQuantityInput.vue +109 -181
  26. package/src/OTPInput.vue +144 -76
  27. package/src/PasswordInput.vue +151 -17
  28. package/src/PersianDatePickerInput.vue +63 -166
  29. package/src/QuantityInput.vue +100 -119
  30. package/src/RangeInput.vue +96 -0
  31. package/src/SimpleUploader.vue +8 -143
  32. package/src/StarRatingInput.vue +114 -138
  33. package/src/SubmitButton.vue +39 -24
  34. package/src/SwitchInput.vue +58 -83
  35. package/src/TelInput.vue +86 -120
  36. package/src/TextAreaInput.vue +29 -33
  37. package/src/TextInput.vue +42 -51
  38. package/src/UppyInput.vue +237 -324
  39. package/src/composables/useBsInstance.js +66 -0
  40. package/src/composables/useChoice.js +34 -0
  41. package/src/composables/useDisposables.js +59 -0
  42. package/src/composables/useField.js +121 -0
  43. package/src/composables/useFormApi.js +12 -0
  44. package/src/i18n.js +120 -0
  45. package/src/index.js +123 -42
  46. package/src/utils/bootstrap.js +48 -0
  47. package/src/utils/digits.js +21 -0
  48. package/src/utils/http.js +15 -0
  49. package/dist/choices-KiM-I_0P.js +0 -2955
  50. package/dist/dropdown-yh2aQrbw-XuCU1v1F.js +0 -1543
  51. package/dist/index-BRtKc2RA.js +0 -604
  52. package/dist/index-_x4UqcXz.js +0 -4938
  53. package/dist/inertia-bootstrap-forms.umd.js +0 -19
  54. package/dist/range-slider-pips-DTDOg3Q9.js +0 -3506
  55. package/dist/style.css +0 -1
  56. package/src/css/from-select.scss +0 -384
  57. package/src/css/loader.gif +0 -0
  58. package/src/css/location.scss +0 -44
  59. 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>