inertia-bootstrap-forms 2.1.20 → 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
package/src/FileInput.vue CHANGED
@@ -1,204 +1,214 @@
1
1
  <script>
2
- import {defineComponent, inject} from "vue";
3
-
2
+ import { defineComponent, reactive, ref, watch } from 'vue';
3
+ import { fieldProps, useField } from './composables/useField.js';
4
+ import { useDisposables } from './composables/useDisposables.js';
5
+ import { useI18n } from './i18n.js';
6
+ import { laravelHeaders } from './utils/http.js';
7
+
8
+ let counter = 0;
9
+
10
+ /**
11
+ * File input that uploads every selected file immediately to `endpoint` (XMLHttpRequest, with progress)
12
+ * and stores the returned path(s) in the form.
13
+ *
14
+ * Endpoint contract: POST multipart `file` → JSON `{ data: { path } }` (or `{ path }`);
15
+ * DELETE with the raw `path` as request body.
16
+ */
4
17
  export default defineComponent({
18
+ name: 'FileInput',
19
+ emits: ['update:modelValue', 'change', 'added', 'progress', 'uploaded', 'error', 'removed'],
5
20
  props: {
6
- modelValue: '',
7
- name: {
8
- type: String,
9
- required: true,
10
- },
11
- multiple: {
12
- type: Boolean,
13
- default: false,
14
- },
15
- endpoint: {
16
- type: String,
17
- default: '/upload',
18
- },
21
+ ...fieldProps,
22
+ multiple: { type: Boolean, default: false },
23
+ endpoint: { type: String, default: '/upload' },
24
+ accept: { type: String, default: null },
25
+ headers: { type: Object, default: () => ({}) },
26
+ /** Name of the multipart field. */
27
+ fieldName: { type: String, default: 'file' },
28
+ locale: { type: String, default: null },
19
29
  },
20
- setup(props) {
21
- let form = inject('form');
30
+ setup(props, { emit, expose }) {
31
+ const field = useField(props, emit);
32
+ const { t } = useI18n(() => props.locale);
33
+ const d = useDisposables();
34
+
35
+ const input = ref(null);
36
+ const files = ref([]);
37
+ const requests = new Map();
38
+
39
+ d.add(() => {
40
+ requests.forEach((xhr) => xhr.abort());
41
+ requests.clear();
42
+ if (field.form.value) field.form.value.uploading = null;
43
+ });
44
+
45
+ const paths = () => files.value.filter((f) => f.path).map((f) => f.path);
46
+
47
+ function publish(event) {
48
+ const next = props.multiple ? paths() : (paths()[0] ?? null);
49
+ field.model.value = next;
50
+ field.emitChange(next, event);
51
+ }
52
+
53
+ function updateProgress() {
54
+ const form = field.form.value;
55
+ if (!form) return;
56
+ const active = files.value.filter((f) => f.uploading);
57
+ form.uploading = active.length ? Math.min(99, Math.round(active.reduce((s, f) => s + f.percent, 0) / active.length)) : null;
58
+ }
22
59
 
23
- if (form === undefined) {
24
- form = {
25
- errors: {},
26
- getID(name) {
60
+ function upload(item) {
61
+ item.error = '';
62
+ item.percent = 0;
63
+ item.uploading = true;
64
+ updateProgress();
65
+
66
+ const body = new FormData();
67
+ body.append(props.fieldName, item.file);
68
+
69
+ const xhr = new XMLHttpRequest();
70
+ requests.set(item.id, xhr);
71
+ xhr.open('POST', props.endpoint);
72
+ Object.entries(laravelHeaders(props.headers)).forEach(([k, v]) => xhr.setRequestHeader(k, v));
73
+
74
+ xhr.upload.onprogress = (e) => {
75
+ if (e.lengthComputable) {
76
+ item.percent = Math.round((e.loaded / e.total) * 100);
77
+ emit('progress', item, item.percent);
78
+ updateProgress();
27
79
  }
28
80
  };
29
- }
30
81
 
31
- return {form};
32
- },
33
- computed: {
34
- inputID() {
35
- return this.form?.getID(this);
36
- },
37
- allFiles() {
38
- return this.files
39
- },
40
- },
41
- methods: {
42
- addFile(file) {
43
- let uploadedFile = {
44
- id: crypto.randomUUID(),
45
- percent: 0,
46
- error: 0,
47
- file: file,
82
+ const fail = (message) => {
83
+ item.uploading = false;
84
+ item.error = message || t('upload.failed');
85
+ requests.delete(item.id);
86
+ updateProgress();
87
+ emit('error', item, item.error);
48
88
  };
49
89
 
50
- if (this.multiple) {
51
- this.files.push(uploadedFile);
52
- } else {
53
- this.files = [uploadedFile];
54
- }
90
+ xhr.onerror = () => fail();
91
+ xhr.onload = () => {
92
+ let json = null;
93
+ try {
94
+ json = JSON.parse(xhr.responseText);
95
+ } catch (e) { /* not json */ }
55
96
 
56
- this.uploadFile(uploadedFile);
57
- this.$refs.input.value = '';
58
- },
59
- calculatePercent(event, file) {
60
- if (event.lengthComputable) {
61
- this.files.find(item => item.id === file.id)['percent'] = Math.round((event.loaded / event.total) * 100);
62
- }
63
- },
64
- addFileToInputValue(file, res) {
65
- this.files.find(item => item.id === file.id).path = res.data.path;
66
-
67
- this.form[this.name] = this.multiple ? this.files.map(item => item.path) : this.files[0].path;
68
- this.$emit('update:modelValue', this.form[this.name]);
69
- },
70
- async uploadFile(file) {
71
- let _this = this;
72
- let formData = new FormData();
73
- formData.append('file', file.file);
74
-
75
- _this.files.find(item => item.id === file.id)['error'] = '';
76
-
77
- await axios.post(this.endpoint,
78
- formData, {
79
- headers: {
80
- 'Content-Type': 'multipart/form-data'
81
- },
82
- onUploadProgress: progressEvent => _this.calculatePercent(progressEvent, file)
97
+ if (xhr.status < 200 || xhr.status >= 300) {
98
+ const first = json?.errors ? Object.values(json.errors)[0] : null;
99
+ return fail(json?.message || (Array.isArray(first) ? first[0] : first));
83
100
  }
84
- ).then(function (res) {
85
- if(res.errors) {
86
- throw res;
87
- }
88
-
89
- _this.addFileToInputValue(file, res)
90
- }).catch(function (err) {
91
- _this.files.find(item => item.id === file.id)['error'] = (err.response?.data?.message || err.message);
92
- });
93
- },
94
- async deleteFile(file) {
95
- this.files = this.files.filter(item => item.id !== file.id);
96
- await axios.delete(this.endpoint, {
97
- data: file.path
98
- });
99
- },
100
- fileSelected(e) {
101
- Array.from(e.target.files).forEach(file => this.addFile(file));
101
+
102
+ const path = json?.data?.path ?? json?.path;
103
+ if (!path) return fail();
104
+
105
+ item.path = path;
106
+ item.percent = 100;
107
+ item.uploading = false;
108
+ requests.delete(item.id);
109
+ updateProgress();
110
+ publish();
111
+ emit('uploaded', item);
112
+ };
113
+
114
+ xhr.send(body);
102
115
  }
103
- },
104
- data() {
105
- return {
106
- files: [],
116
+
117
+ function add(file) {
118
+ const item = reactive({ id: `f${++counter}`, file, percent: 0, error: '', path: null, uploading: false });
119
+ files.value = props.multiple ? [...files.value, item] : [item];
120
+ emit('added', item);
121
+ upload(item);
122
+ }
123
+
124
+ function onSelect(event) {
125
+ Array.from(event.target.files).forEach(add);
126
+ event.target.value = '';
107
127
  }
108
- }
109
- })
128
+
129
+ async function remove(item) {
130
+ requests.get(item.id)?.abort();
131
+ requests.delete(item.id);
132
+ files.value = files.value.filter((f) => f.id !== item.id);
133
+ updateProgress();
134
+
135
+ if (item.path) {
136
+ publish();
137
+ try {
138
+ await fetch(props.endpoint, {
139
+ method: 'DELETE',
140
+ headers: laravelHeaders(props.headers),
141
+ credentials: 'same-origin',
142
+ body: item.path,
143
+ });
144
+ } catch (e) { /* the file is already detached from the form */ }
145
+ }
146
+ emit('removed', item);
147
+ }
148
+
149
+ // The form was reset (e.g. resetOnSuccess): forget the list without touching the server
150
+ watch(
151
+ () => field.model.value,
152
+ (v) => {
153
+ const empty = v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length);
154
+ if (empty && files.value.some((f) => f.path)) files.value = files.value.filter((f) => !f.path);
155
+ }
156
+ );
157
+
158
+ expose({ clear: () => files.value.slice().forEach(remove) });
159
+
160
+ return { ...field, t, input, files, onSelect, remove, retry: upload, size: (b) => `${Math.round(b / 1024)}KB` };
161
+ },
162
+ });
110
163
  </script>
164
+
111
165
  <template>
112
166
  <div class="file-input-uploader">
113
167
  <input
114
168
  ref="input"
115
- :name="name"
116
- :id="inputID"
117
- :class="{'is-invalid': form?.errors[name] !== undefined}"
118
- :disabled="form?.processing"
119
169
  type="file"
170
+ class="form-control"
171
+ :class="{ 'is-invalid': isInvalid }"
172
+ :name="name"
173
+ :id="inputId"
120
174
  :multiple="multiple"
121
- @change="fileSelected"
122
- class="form-control">
123
- <div class="file-input-uploader--list">
124
- <div :id="'file-'+file.id" class="file-input-uploader--list-item" v-for="file in allFiles">
125
- <div class="file-input-uploader--progress" v-if="file.percent && file.percent< 100">
126
- <div :style="{width: file.percent + '%'}"></div>
175
+ :accept="accept || undefined"
176
+ :required="required && !files.length"
177
+ :disabled="isDisabled || readonly"
178
+ :aria-invalid="isInvalid || undefined"
179
+ @change="onSelect"
180
+ />
181
+ <ul v-if="files.length" class="list-group mt-2">
182
+ <li v-for="item in files" :key="item.id" class="list-group-item">
183
+ <div class="d-flex align-items-center justify-content-between gap-2">
184
+ <span class="text-truncate">{{ item.file.name }}</span>
185
+ <span class="d-flex align-items-center gap-2">
186
+ <span class="fg-secondary fanum" dir="ltr">{{ size(item.file.size) }}</span>
187
+ <button
188
+ v-if="item.error"
189
+ type="button"
190
+ class="btn-text theme-secondary btn-icon"
191
+ :aria-label="t('file.retry')"
192
+ @click="retry(item)"
193
+ >
194
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.5 3v3.5H10" /><path d="M3.2 6.5A5.5 5.5 0 0 1 13 6.5M12.8 9.5A5.5 5.5 0 0 1 3 9.5M2.5 13V9.5H6" /></svg>
195
+ </button>
196
+ <button
197
+ v-if="item.path || item.error"
198
+ type="button"
199
+ class="btn-text theme-danger btn-icon"
200
+ :aria-label="t('file.remove')"
201
+ @click="remove(item)"
202
+ >
203
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="m4 4 8 8M12 4l-8 8" /></svg>
204
+ </button>
205
+ </span>
127
206
  </div>
128
- <div>{{ file.file.name }}</div>
129
- <div class="file-input-uploader--list-error" v-if="file.error">{{ file.error }}</div>
130
- <div class="file-input-uploader--list-item--size" dir="ltr">{{ Math.round(file.file.size / 1024) }}Kb</div>
131
- <div class="file-input-uploader--list-item--actions">
132
- <button @click="uploadFile(file)" type="button" class="file-input-uploader--list-item--btn" v-if="file.error">
133
- <svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
134
- <path
135
- d="M1,12A11,11,0,0,1,17.882,2.7l1.411-1.41A1,1,0,0,1,21,2V6a1,1,0,0,1-1,1H16a1,1,0,0,1-.707-1.707l1.128-1.128A8.994,8.994,0,0,0,3,12a1,1,0,0,1-2,0Zm21-1a1,1,0,0,0-1,1,9.01,9.01,0,0,1-9,9,8.9,8.9,0,0,1-4.42-1.166l1.127-1.127A1,1,0,0,0,8,17H4a1,1,0,0,0-1,1v4a1,1,0,0,0,.617.924A.987.987,0,0,0,4,23a1,1,0,0,0,.707-.293L6.118,21.3A10.891,10.891,0,0,0,12,23,11.013,11.013,0,0,0,23,12,1,1,0,0,0,22,11Z"/>
136
- </svg>
137
- </button>
138
- <button @click="deleteFile(file)" type="button" class="file-input-uploader--list-item--btn" v-if="file.path || file.error">
139
- <svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
140
- <path
141
- d="M12,23A11,11,0,1,0,1,12,11.013,11.013,0,0,0,12,23ZM12,3a9,9,0,1,1-9,9A9.01,9.01,0,0,1,12,3ZM8.293,14.293,10.586,12,8.293,9.707A1,1,0,0,1,9.707,8.293L12,10.586l2.293-2.293a1,1,0,0,1,1.414,1.414L13.414,12l2.293,2.293a1,1,0,1,1-1.414,1.414L12,13.414,9.707,15.707a1,1,0,0,1-1.414-1.414Z"/>
142
- </svg>
143
- </button>
207
+ <div v-if="item.uploading" class="progress mt-2" role="progressbar" :aria-valuenow="item.percent" aria-valuemin="0" aria-valuemax="100">
208
+ <div class="progress-bar" :style="{ width: item.percent + '%' }"></div>
144
209
  </div>
145
- </div>
146
- </div>
210
+ <div v-if="item.error" class="fg-danger mt-1" role="alert">{{ item.error }}</div>
211
+ </li>
212
+ </ul>
147
213
  </div>
148
214
  </template>
149
- <style>
150
- .file-input-uploader .file-input-uploader--list .file-input-uploader--list-item {
151
- background-color: var(--bs-secondary-bg);
152
- border-radius: var(--bs-border-radius);
153
- position: relative;
154
- display: flex;
155
- align-items: center;
156
- margin: 5px 0;
157
- padding: 5px 10px;
158
- }
159
-
160
- .file-input-uploader .file-input-uploader--list .file-input-uploader--list-item--size {
161
- margin-left: auto;
162
- font-size: 90%;
163
- padding: 0 5px;
164
- color: var(--bs-secondary-color);
165
- }
166
-
167
- .file-input-uploader .file-input-uploader--list-item--btn {
168
- display: inline-block;
169
- outline: none;
170
- border: 0;
171
- padding: 7px 2px;
172
- }
173
-
174
- .file-input-uploader .file-input-uploader--list-item--btn:not(:last-child){
175
- margin-right: 5px;
176
- }
177
-
178
- .file-input-uploader .file-input-uploader--list-item--btn > svg {
179
- height: 20px;
180
- width: 20px;
181
- vertical-align: middle;
182
- }
183
-
184
- .file-input-uploader .file-input-uploader--progress {
185
- position: absolute;
186
- top: 2px;
187
- left: 5px;
188
- right: 5px;
189
- }
190
-
191
- .file-input-uploader .file-input-uploader--progress > div {
192
- background-color: #de0021;
193
- height: 7px;
194
- width: 0;
195
- min-width: 1%;
196
- border-radius: var(--bs-border-radius)
197
- }
198
-
199
- .file-input-uploader .file-input-uploader--list-error {
200
- font-size: 90%;
201
- margin: 0 5px;
202
- color: var(--bs-danger, #de0021);
203
- }
204
- </style>
@@ -0,0 +1,75 @@
1
+ <script>
2
+ import { computed, defineComponent, ref } from 'vue';
3
+ import { fieldProps, useField } from './composables/useField.js';
4
+
5
+ /**
6
+ * Bootstrap 6 `.form-adorn`: an input decorated with icons / text at its start and/or end.
7
+ *
8
+ * <FormAdornInput name="site" start-text="https://" end-text=".com" />
9
+ * <FormAdornInput name="search"><template #start><span class="form-adorn-icon"><svg …/></span></template></FormAdornInput>
10
+ */
11
+ export default defineComponent({
12
+ name: 'FormAdornInput',
13
+ inheritAttrs: false,
14
+ emits: ['update:modelValue', 'change', 'input', 'focus', 'blur'],
15
+ props: {
16
+ ...fieldProps,
17
+ type: { type: String, default: 'text' },
18
+ placeholder: { type: String, default: null },
19
+ /** Text shown before the input. */
20
+ startText: { type: String, default: null },
21
+ /** Text shown after the input. */
22
+ endText: { type: String, default: null },
23
+ size: { type: String, default: null },
24
+ },
25
+ setup(props, { emit }) {
26
+ const field = useField(props, emit);
27
+ const input = ref(null);
28
+ const sizeClass = computed(() => (props.size ? `form-control-${props.size}` : null));
29
+
30
+ function focusInput(event) {
31
+ if (event.target === event.currentTarget || event.target.closest?.('.form-adorn-icon, .form-adorn-text')) {
32
+ input.value?.focus();
33
+ }
34
+ }
35
+
36
+ return { ...field, input, sizeClass, focusInput, emit, focus: () => input.value?.focus() };
37
+ },
38
+ expose: ['focus'],
39
+ });
40
+ </script>
41
+
42
+ <template>
43
+ <div
44
+ class="form-control form-adorn"
45
+ :class="[sizeClass, { 'is-invalid': isInvalid }, $attrs.class]"
46
+ :style="$attrs.style"
47
+ @click="focusInput"
48
+ >
49
+ <slot name="start">
50
+ <span v-if="startText" class="form-adorn-text">{{ startText }}</span>
51
+ </slot>
52
+ <input
53
+ ref="input"
54
+ v-model="model"
55
+ v-bind="{ ...$attrs, class: undefined, style: undefined }"
56
+ :name="name"
57
+ :id="inputId"
58
+ :type="type"
59
+ :placeholder="placeholder"
60
+ :required="required"
61
+ :readonly="readonly"
62
+ :disabled="isDisabled"
63
+ :aria-invalid="isInvalid || undefined"
64
+ class="form-ghost"
65
+ :class="{ 'is-invalid': isInvalid }"
66
+ @input="emit('input', model, $event)"
67
+ @change="emitChange(model, $event)"
68
+ @focus="$emit('focus', $event)"
69
+ @blur="$emit('blur', $event)"
70
+ />
71
+ <slot name="end">
72
+ <span v-if="endText" class="form-adorn-text">{{ endText }}</span>
73
+ </slot>
74
+ </div>
75
+ </template>