@bagelink/vue 1.15.508 → 1.15.512
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/components/AccordionItem.vue.d.ts +7 -0
- package/dist/components/AccordionItem.vue.d.ts.map +1 -1
- package/dist/components/Btn.vue.d.ts +4 -2
- package/dist/components/Btn.vue.d.ts.map +1 -1
- package/dist/components/RouterWrapper.vue.d.ts.map +1 -1
- package/dist/components/charts/Donut.vue.d.ts +1 -1
- package/dist/components/charts/Donut.vue.d.ts.map +1 -1
- package/dist/components/charts/Funnel.vue.d.ts +1 -1
- package/dist/components/charts/Funnel.vue.d.ts.map +1 -1
- package/dist/components/charts/MapChart.vue.d.ts +1 -1
- package/dist/components/charts/MapChart.vue.d.ts.map +1 -1
- package/dist/components/charts/RankBars.vue.d.ts +1 -1
- package/dist/components/charts/RankBars.vue.d.ts.map +1 -1
- package/dist/components/dataTable/useTableData.d.ts +1 -1
- package/dist/components/form/inputs/RadioGroup.vue.d.ts +1 -1
- package/dist/components/form/inputs/RadioGroup.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/TelInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -1
- package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
- package/dist/components/kanban/KanbanBoard.vue.d.ts +10 -0
- package/dist/components/kanban/KanbanBoard.vue.d.ts.map +1 -1
- package/dist/components/layout/AppLayout.vue.d.ts +2 -2
- package/dist/components/layout/AppLayout.vue.d.ts.map +1 -1
- package/dist/composables/useDevice.d.ts +1 -1
- package/dist/core.css +1 -1
- package/dist/dialog/Dialog.vue.d.ts +3 -3
- package/dist/dialog/Dialog.vue.d.ts.map +1 -1
- package/dist/dialog/DialogConfirm.vue.d.ts +1 -1
- package/dist/dialog/DialogConfirm.vue.d.ts.map +1 -1
- package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
- package/dist/form-flow/custom-field-registry.d.ts +8 -0
- package/dist/form-flow/custom-field-registry.d.ts.map +1 -1
- package/dist/form-flow/form-flow.d.ts +1 -0
- package/dist/form-flow/form-flow.d.ts.map +1 -1
- package/dist/index.cjs +117 -109
- package/dist/index.css +1 -1
- package/dist/index.mjs +22882 -21361
- package/dist/style.css +1 -1
- package/dist/types/BagelForm.d.ts +2 -0
- package/dist/types/BagelForm.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/AccordionItem.vue +78 -22
- package/src/components/Btn.vue +17 -0
- package/src/components/ListItem.vue +6 -6
- package/src/components/RouterWrapper.vue +21 -1
- package/src/components/form/inputs/TelInput.vue +137 -121
- package/src/components/form/inputs/Upload/UploadInput.vue +33 -2
- package/src/components/form/inputs/Upload/upload.css +8 -0
- package/src/components/kanban/KanbanBoard.vue +7 -1
- package/src/form-flow/FormFlowPlayer.vue +33 -27
- package/src/form-flow/custom-field-registry.ts +9 -0
- package/src/form-flow/form-flow.ts +9 -1
- package/src/types/BagelForm.ts +2 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import type { Country } from '@bagelink/vue'
|
|
3
3
|
import type { CountryCode } from 'libphonenumber-js'
|
|
4
4
|
import { Dropdown, Flag, Icon, TextInput, allCountries, ipapi, useI18n, resolveI18n } from '@bagelink/vue'
|
|
5
|
-
import { parsePhoneNumberFromString } from 'libphonenumber-js'
|
|
5
|
+
import { AsYouType, getExampleNumber, parsePhoneNumberFromString } from 'libphonenumber-js'
|
|
6
|
+
import examples from 'libphonenumber-js/examples.mobile.json'
|
|
6
7
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
7
8
|
import type { BagelInputShellProps } from './bagelInputShell'
|
|
8
9
|
import { useBagelInputShell } from './bagelInputShell'
|
|
@@ -28,35 +29,107 @@ const emit = defineEmits(['update:modelValue', 'blur', 'focus', 'keydown', 'inpu
|
|
|
28
29
|
|
|
29
30
|
const { $t } = useI18n()
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
// Territories that share their parent country's calling code are represented by
|
|
33
|
+
// the parent in the selector. Independent countries sharing a numbering plan
|
|
34
|
+
// (for example Canada and the United States) remain separate options.
|
|
35
|
+
const SHARED_CALLING_CODE_PARENTS: Record<string, string> = {
|
|
36
|
+
AS: 'US',
|
|
37
|
+
AX: 'FI',
|
|
38
|
+
CC: 'AU',
|
|
39
|
+
CX: 'AU',
|
|
40
|
+
GG: 'GB',
|
|
41
|
+
GU: 'US',
|
|
42
|
+
IM: 'GB',
|
|
43
|
+
JE: 'GB',
|
|
44
|
+
MP: 'US',
|
|
45
|
+
PR: 'US',
|
|
46
|
+
SJ: 'NO',
|
|
47
|
+
VI: 'US',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeCountryCode(code: string) {
|
|
51
|
+
const upperCode = code.toUpperCase()
|
|
52
|
+
return SHARED_CALLING_CODE_PARENTS[upperCode] ?? upperCode
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `national` holds ONLY what the user types (no dial code), formatted live via
|
|
57
|
+
* `AsYouType`. `modelValue` stays E.164 (`+31612345678`) — derived from the
|
|
58
|
+
* active country's dial code + the national digits — so consumers are unaffected.
|
|
59
|
+
*/
|
|
60
|
+
const national = ref('')
|
|
32
61
|
const search = ref('')
|
|
33
62
|
const activeCountry = ref<Country>()
|
|
34
63
|
const searchInput = ref<InstanceType<typeof TextInput>>()
|
|
35
64
|
const open = ref(false)
|
|
36
65
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
37
66
|
const isValid = ref(true)
|
|
67
|
+
// Only surface the invalid state after blur, so we don't punish mid-type.
|
|
68
|
+
const touched = ref(false)
|
|
69
|
+
|
|
70
|
+
/** Raw national digits with any trunk-prefix leading zero(s) stripped. */
|
|
71
|
+
function nationalDigits(): string {
|
|
72
|
+
return national.value.replace(/\D/g, '').replace(/^0+/, '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build the E.164 value from the active dial code + national digits. */
|
|
76
|
+
function toE164(): string {
|
|
77
|
+
const digits = nationalDigits()
|
|
78
|
+
if (!digits) return ''
|
|
79
|
+
if (!activeCountry.value) return `+${digits}`
|
|
80
|
+
return `+${activeCountry.value.dialCode}${digits}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Format national input live using libphonenumber's AsYouType.
|
|
84
|
+
* Preserves a leading trunk 0 in the *display* (users often type "06…"); the
|
|
85
|
+
* leading zero is only stripped when we build the E.164 value (see toE164). */
|
|
86
|
+
function formatNational(input: string): string {
|
|
87
|
+
const code = getCountryCode()
|
|
88
|
+
const digits = input.replace(/\D/g, '')
|
|
89
|
+
if (!digits) return ''
|
|
90
|
+
if (!code) return digits
|
|
91
|
+
const leadingZero = digits.startsWith('0') ? '0' : ''
|
|
92
|
+
const formatted = new AsYouType(code).input(digits.replace(/^0+/, ''))
|
|
93
|
+
return leadingZero + formatted
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Emit E.164 whenever the national part or country changes.
|
|
97
|
+
watch([national, activeCountry], () => emit('update:modelValue', toE164()))
|
|
98
|
+
|
|
99
|
+
/** Split an incoming E.164 / raw value into active country + national part. */
|
|
100
|
+
function ingestValue(value?: string) {
|
|
101
|
+
const raw = value ?? ''
|
|
102
|
+
if (!raw) { national.value = ''; return }
|
|
103
|
+
const parsed = parsePhoneNumberFromString(raw, getCountryCode())
|
|
104
|
+
if (parsed) {
|
|
105
|
+
const match = parsed.country ? findCountry(parsed.country) : undefined
|
|
106
|
+
if (match) activeCountry.value = match
|
|
107
|
+
national.value = formatNational(parsed.nationalNumber)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
// Fallback: strip the active dial code if present, else keep digits.
|
|
111
|
+
const digits = raw.replace(/\D/g, '')
|
|
112
|
+
if (activeCountry.value && digits.startsWith(activeCountry.value.dialCode)) {
|
|
113
|
+
national.value = formatNational(digits.slice(activeCountry.value.dialCode.length))
|
|
114
|
+
} else {
|
|
115
|
+
national.value = formatNational(digits)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
38
118
|
|
|
39
|
-
//
|
|
40
|
-
watch(() => phoneNumber.value, (newValue) => {
|
|
41
|
-
emit('update:modelValue', newValue)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
// Watch for changes to the modelValue prop
|
|
119
|
+
// Keep the field in sync when the bound modelValue changes externally.
|
|
45
120
|
watch(() => props.modelValue, (newValue) => {
|
|
46
|
-
if (newValue !==
|
|
47
|
-
phoneNumber.value = newValue ?? ''
|
|
48
|
-
}
|
|
121
|
+
if (newValue !== toE164()) ingestValue(newValue)
|
|
49
122
|
})
|
|
50
123
|
|
|
51
124
|
const countries = computed(() => {
|
|
52
|
-
let filteredCountries = allCountries
|
|
125
|
+
let filteredCountries = allCountries.filter(country => !SHARED_CALLING_CODE_PARENTS[country.iso2])
|
|
53
126
|
if (props.excludeCountries && props.excludeCountries.length) {
|
|
54
127
|
const excludeCountries = props.excludeCountries.map(c => c.toLowerCase())
|
|
55
128
|
filteredCountries = filteredCountries.filter(c => !excludeCountries.includes(c.iso2.toLowerCase()))
|
|
56
129
|
}
|
|
57
130
|
if (props.onlyCountries && props.onlyCountries.length) {
|
|
58
|
-
const onlyCountries = props.onlyCountries.map(
|
|
59
|
-
filteredCountries = filteredCountries.filter(c => onlyCountries.includes(c.iso2
|
|
131
|
+
const onlyCountries = props.onlyCountries.map(normalizeCountryCode)
|
|
132
|
+
filteredCountries = filteredCountries.filter(c => onlyCountries.includes(c.iso2))
|
|
60
133
|
}
|
|
61
134
|
if (search.value.length) {
|
|
62
135
|
const lowerCaseSearch = search.value.toLowerCase()
|
|
@@ -70,11 +143,19 @@ const countries = computed(() => {
|
|
|
70
143
|
|
|
71
144
|
const activeCountryCode = computed(() => activeCountry.value?.iso2)
|
|
72
145
|
|
|
146
|
+
/** A real, correctly-formatted example national number for the active country. */
|
|
147
|
+
const examplePlaceholder = computed(() => {
|
|
148
|
+
const code = getCountryCode()
|
|
149
|
+
if (!code) return ''
|
|
150
|
+
try {
|
|
151
|
+
const example = getExampleNumber(code, examples)
|
|
152
|
+
return example ? example.formatNational() : ''
|
|
153
|
+
} catch { return '' }
|
|
154
|
+
})
|
|
155
|
+
|
|
73
156
|
const computedPlaceholder = computed(() => {
|
|
74
157
|
if (props.placeholder) return resolveI18n(props.placeholder)
|
|
75
|
-
if (
|
|
76
|
-
return `+${activeCountry.value.dialCode} ${resolveI18n(props.label) || $t('tel.phoneNumber')}`
|
|
77
|
-
}
|
|
158
|
+
if (examplePlaceholder.value) return examplePlaceholder.value
|
|
78
159
|
return resolveI18n(props.label) || $t('tel.phoneNumber')
|
|
79
160
|
})
|
|
80
161
|
|
|
@@ -82,30 +163,14 @@ function selectCountry(country: Country) {
|
|
|
82
163
|
activeCountry.value = country
|
|
83
164
|
open.value = false
|
|
84
165
|
search.value = ''
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// Don't auto-fill for non-required empty fields
|
|
88
|
-
if (props.required) {
|
|
89
|
-
phoneNumber.value = `+${activeCountry.value.dialCode}`
|
|
90
|
-
}
|
|
91
|
-
} else if (phoneNumber.value.startsWith('+')) {
|
|
92
|
-
// Replace existing country code with the new one
|
|
93
|
-
const existingCountry = countries.value.find(c => phoneNumber.value.startsWith(`+${c.dialCode}`))
|
|
94
|
-
if (existingCountry && existingCountry !== country) {
|
|
95
|
-
// Remove the old country code and replace with new one
|
|
96
|
-
const nationalPart = phoneNumber.value.substring(`+${existingCountry.dialCode}`.length).trim()
|
|
97
|
-
phoneNumber.value = `+${activeCountry.value.dialCode}${nationalPart ? ` ${nationalPart}` : ''}`
|
|
98
|
-
}
|
|
99
|
-
} else {
|
|
100
|
-
// Phone number doesn't start with +, prepend the country code
|
|
101
|
-
phoneNumber.value = `+${activeCountry.value.dialCode} ${phoneNumber.value.replace(/^0+/, '')}`
|
|
102
|
-
}
|
|
166
|
+
// Re-run the current national digits through the new country's formatter.
|
|
167
|
+
national.value = formatNational(national.value)
|
|
103
168
|
}
|
|
104
169
|
|
|
105
170
|
function findCountry(iso2?: string | null) {
|
|
106
171
|
if (!iso2) return undefined
|
|
107
|
-
const code = iso2
|
|
108
|
-
return countries.value.find(c => c.iso2
|
|
172
|
+
const code = normalizeCountryCode(iso2)
|
|
173
|
+
return countries.value.find(c => c.iso2 === code)
|
|
109
174
|
}
|
|
110
175
|
|
|
111
176
|
/** Locale / language fallback when geo-IP is blocked (CORS, rate limit, offline). */
|
|
@@ -138,70 +203,33 @@ function getCountryCode(): CountryCode | undefined {
|
|
|
138
203
|
return activeCountry.value?.iso2 ? activeCountry.value.iso2.toUpperCase() as CountryCode : undefined
|
|
139
204
|
}
|
|
140
205
|
|
|
141
|
-
|
|
142
|
-
function parseAndFormatPhoneNumber(value: string): string {
|
|
143
|
-
if (!value) { return value }
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
const parsedNumber = parsePhoneNumberFromString(value, getCountryCode())
|
|
147
|
-
if (parsedNumber && parsedNumber.isValid()) {
|
|
148
|
-
return parsedNumber.formatInternational()
|
|
149
|
-
}
|
|
150
|
-
} catch (error) {
|
|
151
|
-
console.error('Error parsing phone number:', error)
|
|
152
|
-
}
|
|
153
|
-
return value
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Validate the phone number and set custom validity
|
|
206
|
+
/** Validate the E.164 value and set the native custom validity. */
|
|
157
207
|
function validatePhoneNumber() {
|
|
158
208
|
if (!inputRef.value) { return }
|
|
209
|
+
const value = toE164()
|
|
159
210
|
|
|
160
|
-
// If not required and empty (or just a
|
|
161
|
-
if (!props.required) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
inputRef.value.setCustomValidity('')
|
|
166
|
-
isValid.value = true
|
|
167
|
-
return
|
|
168
|
-
}
|
|
211
|
+
// If not required and empty (or just a dial code), skip validation.
|
|
212
|
+
if (!props.required && !nationalDigits()) {
|
|
213
|
+
inputRef.value.setCustomValidity('')
|
|
214
|
+
isValid.value = true
|
|
215
|
+
return
|
|
169
216
|
}
|
|
170
217
|
|
|
171
218
|
try {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
} else {
|
|
177
|
-
inputRef.value.setCustomValidity($t('tel.invalidPhone'))
|
|
178
|
-
isValid.value = false
|
|
179
|
-
}
|
|
219
|
+
const parsed = parsePhoneNumberFromString(value, getCountryCode())
|
|
220
|
+
const ok = Boolean(parsed && parsed.isValid())
|
|
221
|
+
inputRef.value.setCustomValidity(ok ? '' : $t('tel.invalidPhone'))
|
|
222
|
+
isValid.value = ok || !touched.value
|
|
180
223
|
} catch (error) {
|
|
181
224
|
console.error('Error validating phone number:', error)
|
|
182
225
|
inputRef.value.setCustomValidity($t('tel.invalidPhone'))
|
|
183
|
-
isValid.value =
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function detectCountryFromNumber(value: string): boolean {
|
|
188
|
-
if (!value.startsWith('+')) { return false }
|
|
189
|
-
|
|
190
|
-
const digits = value.replace(/\D/g, '')
|
|
191
|
-
if (digits.length <= 1) { return false }
|
|
192
|
-
|
|
193
|
-
for (const country of countries.value) {
|
|
194
|
-
if (digits.startsWith(country.dialCode) && country !== activeCountry.value) {
|
|
195
|
-
selectCountry(country)
|
|
196
|
-
return true
|
|
197
|
-
}
|
|
226
|
+
isValid.value = !touched.value
|
|
198
227
|
}
|
|
199
|
-
return false
|
|
200
228
|
}
|
|
201
229
|
|
|
202
230
|
async function initializeCountry() {
|
|
203
|
-
if (
|
|
204
|
-
|
|
231
|
+
if (props.modelValue) {
|
|
232
|
+
ingestValue(props.modelValue)
|
|
205
233
|
} else {
|
|
206
234
|
try {
|
|
207
235
|
await getIp()
|
|
@@ -209,43 +237,25 @@ async function initializeCountry() {
|
|
|
209
237
|
selectFallbackCountry()
|
|
210
238
|
}
|
|
211
239
|
}
|
|
212
|
-
const formatted = parseAndFormatPhoneNumber(phoneNumber.value)
|
|
213
|
-
if (formatted !== phoneNumber.value) {
|
|
214
|
-
phoneNumber.value = formatted
|
|
215
|
-
emit('input', phoneNumber.value)
|
|
216
|
-
validatePhoneNumber()
|
|
217
|
-
}
|
|
218
240
|
validatePhoneNumber()
|
|
219
241
|
}
|
|
220
242
|
|
|
221
243
|
function handlePhoneInput(event: Event) {
|
|
222
244
|
const input = event.target as HTMLInputElement
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (formatted !== value) {
|
|
229
|
-
phoneNumber.value = formatted
|
|
230
|
-
emit('input', event)
|
|
231
|
-
validatePhoneNumber()
|
|
232
|
-
return
|
|
233
|
-
}
|
|
245
|
+
// If the user pastes/types a full international number, re-detect country.
|
|
246
|
+
if (input.value.trim().startsWith('+')) {
|
|
247
|
+
ingestValue(input.value)
|
|
248
|
+
} else {
|
|
249
|
+
national.value = formatNational(input.value)
|
|
234
250
|
}
|
|
235
|
-
|
|
236
|
-
|
|
251
|
+
// Reflect the formatted value back into the field.
|
|
252
|
+
input.value = national.value
|
|
237
253
|
emit('input', event)
|
|
238
|
-
validatePhoneNumber()
|
|
254
|
+
if (touched.value) validatePhoneNumber()
|
|
239
255
|
}
|
|
240
256
|
|
|
241
257
|
function handleBlur(event: Event) {
|
|
242
|
-
|
|
243
|
-
const nationalNumber = phoneNumber.value.replace(/^0+/, '')
|
|
244
|
-
phoneNumber.value = `+${activeCountry.value.dialCode} ${nationalNumber}`
|
|
245
|
-
} else if (phoneNumber.value) {
|
|
246
|
-
phoneNumber.value = parseAndFormatPhoneNumber(phoneNumber.value)
|
|
247
|
-
}
|
|
248
|
-
|
|
258
|
+
touched.value = true
|
|
249
259
|
validatePhoneNumber()
|
|
250
260
|
emit('blur', event)
|
|
251
261
|
}
|
|
@@ -253,10 +263,7 @@ function handleBlur(event: Event) {
|
|
|
253
263
|
const disableDropdown = computed(() => countries.value.length === 1 && !search.value)
|
|
254
264
|
const searchable = computed(() => countries.value.length > 7 || search.value)
|
|
255
265
|
|
|
256
|
-
const hasValue = computed(() =>
|
|
257
|
-
const val = phoneNumber.value
|
|
258
|
-
return Boolean(val && String(val).length > 0)
|
|
259
|
-
})
|
|
266
|
+
const hasValue = computed(() => Boolean(national.value))
|
|
260
267
|
|
|
261
268
|
function focusSearchInput() {
|
|
262
269
|
setTimeout(() => searchInput.value?.focus(), 100)
|
|
@@ -284,6 +291,7 @@ onMounted(initializeCountry)
|
|
|
284
291
|
<span class="flex gap-05 country-code-display" @click="open = !open">
|
|
285
292
|
<Icon v-if="!disableDropdown" :icon="open ? 'collapse_all' : 'expand_all'" />
|
|
286
293
|
<Flag v-if="activeCountryCode" :country="activeCountryCode" />
|
|
294
|
+
<span v-if="activeCountry" class="dial-code">+{{ activeCountry.dialCode }}</span>
|
|
287
295
|
</span>
|
|
288
296
|
</template>
|
|
289
297
|
<div class="p-075 tel-countryp-dropdown">
|
|
@@ -308,11 +316,13 @@ onMounted(initializeCountry)
|
|
|
308
316
|
</div>
|
|
309
317
|
</Dropdown>
|
|
310
318
|
<input
|
|
311
|
-
:id="id" ref="inputRef"
|
|
312
|
-
:placeholder="underlined ? ' ' : computedPlaceholder" :disabled="disabled" type="tel"
|
|
319
|
+
:id="id" ref="inputRef" :value="national" v-pattern.tel :required="required"
|
|
320
|
+
:placeholder="underlined ? ' ' : computedPlaceholder" :disabled="disabled" type="tel"
|
|
321
|
+
inputmode="tel" autocomplete="tel-national" :name="id"
|
|
313
322
|
tabindex="0" class="national-number-input" @blur="handleBlur($event)" @focus="emit('focus', $event)"
|
|
314
323
|
@keydown="emit('keydown', $event)" @input="handlePhoneInput($event)" @paste="emit('paste', $event)"
|
|
315
324
|
>
|
|
325
|
+
<Icon v-if="touched && isValid && hasValue" icon="check" class="valid-check color-green" />
|
|
316
326
|
</div>
|
|
317
327
|
</label>
|
|
318
328
|
<div v-if="error" class="error-message">
|
|
@@ -392,6 +402,12 @@ align-items: center;
|
|
|
392
402
|
white-space: nowrap;
|
|
393
403
|
}
|
|
394
404
|
|
|
405
|
+
.valid-check {
|
|
406
|
+
flex-shrink: 0;
|
|
407
|
+
margin-inline-end: 0.4rem;
|
|
408
|
+
font-size: 1rem;
|
|
409
|
+
}
|
|
410
|
+
|
|
395
411
|
.dial-code {
|
|
396
412
|
font-size: var(--bgl-input-font-size);
|
|
397
413
|
color: var(--bgl-input-color);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { UploadInputProps } from '../../../../types/BagelForm'
|
|
3
3
|
import { Btn, IMAGE_FORMATS_REGEXP, Icon, Card, Image, pathKeyToURL, Loading, useI18n, resolveI18n } from '@bagelink/vue'
|
|
4
|
-
import { watch, computed, useSlots } from 'vue'
|
|
4
|
+
import { watch, computed, ref, useSlots } from 'vue'
|
|
5
5
|
import { useFileDropzone } from '../../../file-drop/useFileDropzone'
|
|
6
6
|
import { useFileUpload } from './useFileUpload'
|
|
7
7
|
import { useBagelInputShell } from '../bagelInputShell'
|
|
@@ -13,6 +13,7 @@ const props = withDefaults(defineProps<UploadInputProps>(), {
|
|
|
13
13
|
showIcon: true,
|
|
14
14
|
icon: 'upload_2',
|
|
15
15
|
iconSize: 2.5,
|
|
16
|
+
uploadFrame: true,
|
|
16
17
|
})
|
|
17
18
|
|
|
18
19
|
const emit = defineEmits(['update:modelValue', 'addFileStart'])
|
|
@@ -77,7 +78,26 @@ const showDropZonePlaceholder = computed(
|
|
|
77
78
|
() => (!pathKeys.value.length && !fileQueue.value.length) || !!props.multiple
|
|
78
79
|
)
|
|
79
80
|
|
|
80
|
-
const
|
|
81
|
+
const acceptsImages = computed(() => props.accept
|
|
82
|
+
.split(',')
|
|
83
|
+
.some(type => type.trim().toLowerCase().startsWith('image/')))
|
|
84
|
+
const detectedImageKeys = ref(new Set<string>())
|
|
85
|
+
const probedImageKeys = new Set<string>()
|
|
86
|
+
const isImage = (str: string) => acceptsImages.value
|
|
87
|
+
|| IMAGE_FORMATS_REGEXP.test(str.split(/[?#]/)[0] ?? '')
|
|
88
|
+
|| detectedImageKeys.value.has(str)
|
|
89
|
+
|
|
90
|
+
function probeImage(pathKey: string) {
|
|
91
|
+
if (typeof window === 'undefined' || isImage(pathKey) || probedImageKeys.has(pathKey)) return
|
|
92
|
+
probedImageKeys.add(pathKey)
|
|
93
|
+
const image = new window.Image()
|
|
94
|
+
image.onload = () => {
|
|
95
|
+
detectedImageKeys.value = new Set([...detectedImageKeys.value, pathKey])
|
|
96
|
+
}
|
|
97
|
+
image.src = pathKeyToURL(pathKey) ?? pathKey
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
watch(pathKeys, keys => keys.forEach(probeImage), { immediate: true })
|
|
81
101
|
|
|
82
102
|
const { isDragging: isDragOver, handlers } = useFileDropzone({
|
|
83
103
|
disabled: () => props.disabled,
|
|
@@ -152,6 +172,7 @@ function fileName(pathKey: string) {
|
|
|
152
172
|
'fileDropZone': !pathKeys.length && !fileQueue.length,
|
|
153
173
|
'dragover': isDragOver,
|
|
154
174
|
'bgl_oval-upload': oval,
|
|
175
|
+
'border overflow-hidden': props.uploadFrame,
|
|
155
176
|
}" :style="{ width, height }" v-on="handlers" @click="browse()"
|
|
156
177
|
>
|
|
157
178
|
<slot name="files" :files="pathKeys" :fileQueue>
|
|
@@ -265,3 +286,13 @@ function fileName(pathKey: string) {
|
|
|
265
286
|
</template>
|
|
266
287
|
|
|
267
288
|
<style src="./upload.css" scoped></style>
|
|
289
|
+
<style>
|
|
290
|
+
.bgl-single-preview:hover .bgl_btn{
|
|
291
|
+
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.6));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.single-image-item-preview::after{
|
|
295
|
+
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.6));
|
|
296
|
+
|
|
297
|
+
}
|
|
298
|
+
</style>
|
|
@@ -123,10 +123,18 @@
|
|
|
123
123
|
pointer-events: none;
|
|
124
124
|
opacity: 0;
|
|
125
125
|
transition: all 0.4s ease;
|
|
126
|
+
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.6));
|
|
127
|
+
background: #00000050;
|
|
128
|
+
height: 50px;
|
|
129
|
+
width: 50px;
|
|
130
|
+
display: flex;
|
|
131
|
+
align-items: center;
|
|
132
|
+
justify-content: center;
|
|
126
133
|
}
|
|
127
134
|
|
|
128
135
|
.single-image-item-preview:hover::after {
|
|
129
136
|
opacity: 1;
|
|
137
|
+
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
.single-image-item-preview img {
|
|
@@ -21,6 +21,8 @@ const slots = defineSlots<{
|
|
|
21
21
|
card?: (p: { item: T; column: KanbanColumn<T> }) => any
|
|
22
22
|
'column-header'?: (p: { column: KanbanColumn<T>; count: number }) => any
|
|
23
23
|
'column-footer'?: (p: { column: KanbanColumn<T> }) => any
|
|
24
|
+
/** Replaces the default spinner while a column loads. `initial` = no items yet. */
|
|
25
|
+
loading?: (p: { column: KanbanColumn<T>; initial: boolean }) => any
|
|
24
26
|
}>()
|
|
25
27
|
|
|
26
28
|
/* ── mode resolution ───────────────────────────────────────────── */
|
|
@@ -158,7 +160,11 @@ function cardBadges(item: T) {
|
|
|
158
160
|
</div>
|
|
159
161
|
|
|
160
162
|
<EmptyState v-if="!col.items.length && !col.loading" compact icon="inbox" :title="emptyText" class="bgl-kanban-empty py-075 opacity-5" />
|
|
161
|
-
<
|
|
163
|
+
<template v-if="col.loading">
|
|
164
|
+
<slot name="loading" :column="col" :initial="!col.items.length">
|
|
165
|
+
<Loading class="bgl-kanban-loading p-1 display-flex justify-content-center" />
|
|
166
|
+
</slot>
|
|
167
|
+
</template>
|
|
162
168
|
<!-- footer / load more -->
|
|
163
169
|
<div v-if="canLoadMore(col) || slots['column-footer']" class="bgl-kanban-foot">
|
|
164
170
|
<slot name="column-footer" :column="col">
|
|
@@ -252,19 +252,24 @@ function validateStep(): boolean {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
async function onPrimary() {
|
|
255
|
+
// Single-flight: the lock spans `beforePrimary` too, since hosts may do
|
|
256
|
+
// network work there. Double clicks / auto-advance can't re-enter.
|
|
257
|
+
if (submitting.value) return
|
|
255
258
|
if (!validateStep()) return
|
|
256
259
|
|
|
257
260
|
const step = currentStep.value
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
261
|
+
submitting.value = true
|
|
262
|
+
// Stay locked while a post-submit redirect is navigating away.
|
|
263
|
+
let keepLocked = false
|
|
264
|
+
try {
|
|
265
|
+
const proceed = await props.beforePrimary?.({
|
|
266
|
+
data: { ...formData.value },
|
|
267
|
+
currentStep: step!,
|
|
268
|
+
isTerminal: isTerminal.value,
|
|
269
|
+
})
|
|
270
|
+
if (proceed === false) return
|
|
271
|
+
|
|
272
|
+
if (isTerminal.value) {
|
|
268
273
|
const submittedData = normalizeCustomFormSubmission(allFields.value, formData.value)
|
|
269
274
|
// `skipSubmit` terminals (e.g. disqualification leaves) end the flow
|
|
270
275
|
// without creating a submission — the host's onComplete is bypassed,
|
|
@@ -277,32 +282,33 @@ async function onPrimary() {
|
|
|
277
282
|
// can unmount this component before navigation executes.
|
|
278
283
|
const url = step?.redirectUrl?.trim()
|
|
279
284
|
if (url) {
|
|
285
|
+
keepLocked = true
|
|
280
286
|
emit('redirect', url)
|
|
281
287
|
if (props.redirect && typeof window !== 'undefined') {
|
|
282
288
|
window.location.assign(url)
|
|
283
289
|
}
|
|
284
290
|
}
|
|
285
291
|
emit('complete', { ...formData.value })
|
|
292
|
+
return
|
|
286
293
|
}
|
|
287
|
-
|
|
288
|
-
|
|
294
|
+
|
|
295
|
+
// Advance to the resolved next step.
|
|
296
|
+
const target = nextKey.value
|
|
297
|
+
if (target && currentKey.value) {
|
|
298
|
+
completedKeys.value = new Set([...completedKeys.value, currentKey.value])
|
|
299
|
+
const destination = stepByKey(props.structure, target)
|
|
300
|
+
emit('stepComplete', {
|
|
301
|
+
...buildPayload(currentKey.value),
|
|
302
|
+
destinationStepKey: target,
|
|
303
|
+
destinationStepTitle: destination?.title ?? '',
|
|
304
|
+
})
|
|
305
|
+
slideDir.value = 'next'
|
|
306
|
+
history.value.push(currentKey.value)
|
|
307
|
+
currentKey.value = target
|
|
289
308
|
}
|
|
290
|
-
return
|
|
291
309
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const target = nextKey.value
|
|
295
|
-
if (target && currentKey.value) {
|
|
296
|
-
completedKeys.value = new Set([...completedKeys.value, currentKey.value])
|
|
297
|
-
const destination = stepByKey(props.structure, target)
|
|
298
|
-
emit('stepComplete', {
|
|
299
|
-
...buildPayload(currentKey.value),
|
|
300
|
-
destinationStepKey: target,
|
|
301
|
-
destinationStepTitle: destination?.title ?? '',
|
|
302
|
-
})
|
|
303
|
-
slideDir.value = 'next'
|
|
304
|
-
history.value.push(currentKey.value)
|
|
305
|
-
currentKey.value = target
|
|
310
|
+
finally {
|
|
311
|
+
if (!keepLocked) submitting.value = false
|
|
306
312
|
}
|
|
307
313
|
}
|
|
308
314
|
|
|
@@ -21,6 +21,13 @@ export interface CustomFormFieldJSONSchemaContext {
|
|
|
21
21
|
required: boolean
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface CustomFormFieldTranslatableSetting<TConfig extends Record<string, unknown>> {
|
|
25
|
+
/** Key stored in the custom field config (normally under `x-field-settings`). */
|
|
26
|
+
key: Extract<keyof TConfig, string>
|
|
27
|
+
/** Human-readable label used by translation editors. */
|
|
28
|
+
label: string
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
export interface CustomFormFieldDefinition<
|
|
25
32
|
TValue = unknown,
|
|
26
33
|
TConfig extends Record<string, unknown> = Record<string, unknown>,
|
|
@@ -32,6 +39,8 @@ export interface CustomFormFieldDefinition<
|
|
|
32
39
|
settingsComponent?: Component
|
|
33
40
|
settingsConfig?: Record<string, unknown>
|
|
34
41
|
settingsDefaults?: Partial<TConfig>
|
|
42
|
+
/** String settings that locale editors may translate without changing behavior. */
|
|
43
|
+
translatableSettings?: CustomFormFieldTranslatableSetting<TConfig>[]
|
|
35
44
|
jsonSchema?: {
|
|
36
45
|
type?: string
|
|
37
46
|
parse?: (
|
|
@@ -603,7 +603,14 @@ class Schema<T extends Record<string, any> = Record<string, any>> implements Sch
|
|
|
603
603
|
|
|
604
604
|
// Field type (useful for rendering)
|
|
605
605
|
if (field._type !== 'text') xUi.fieldType = field._type
|
|
606
|
-
if (customDefinition)
|
|
606
|
+
if (customDefinition) {
|
|
607
|
+
prop['x-field-type'] = field._type
|
|
608
|
+
const translatableSettings = customDefinition.translatableSettings
|
|
609
|
+
?.map(setting => setting.key)
|
|
610
|
+
if (translatableSettings?.length) {
|
|
611
|
+
prop['x-translatable-settings'] = translatableSettings
|
|
612
|
+
}
|
|
613
|
+
}
|
|
607
614
|
|
|
608
615
|
// UI-specific hints
|
|
609
616
|
if (config.placeholder) xUi.placeholder = config.placeholder
|
|
@@ -842,6 +849,7 @@ interface JSONSchemaProperty {
|
|
|
842
849
|
// (schema-fields.ts `fieldsToSchema`). fromJSONSchema honors these too so a
|
|
843
850
|
// designer-built schema renders identically in FormFlow/FormFlowPlayer.
|
|
844
851
|
'x-field-type'?: string
|
|
852
|
+
'x-translatable-settings'?: string[]
|
|
845
853
|
'x-options'?: { label: string, value: any }[]
|
|
846
854
|
'x-options-source'?: JSONSchemaOptionsSource
|
|
847
855
|
'x-placeholder'?: string
|
package/src/types/BagelForm.ts
CHANGED
|
@@ -207,6 +207,8 @@ export interface UploadInputProps extends BagelInputShellProps {
|
|
|
207
207
|
height?: string | 'auto'
|
|
208
208
|
dirPath?: string
|
|
209
209
|
fill?: boolean
|
|
210
|
+
/** Adds a border and clips the upload preview to its frame. */
|
|
211
|
+
uploadFrame?: boolean
|
|
210
212
|
oval?: boolean
|
|
211
213
|
theme?: 'dropzone' | 'basic'
|
|
212
214
|
accept?: string
|