@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.
Files changed (53) hide show
  1. package/dist/components/AccordionItem.vue.d.ts +7 -0
  2. package/dist/components/AccordionItem.vue.d.ts.map +1 -1
  3. package/dist/components/Btn.vue.d.ts +4 -2
  4. package/dist/components/Btn.vue.d.ts.map +1 -1
  5. package/dist/components/RouterWrapper.vue.d.ts.map +1 -1
  6. package/dist/components/charts/Donut.vue.d.ts +1 -1
  7. package/dist/components/charts/Donut.vue.d.ts.map +1 -1
  8. package/dist/components/charts/Funnel.vue.d.ts +1 -1
  9. package/dist/components/charts/Funnel.vue.d.ts.map +1 -1
  10. package/dist/components/charts/MapChart.vue.d.ts +1 -1
  11. package/dist/components/charts/MapChart.vue.d.ts.map +1 -1
  12. package/dist/components/charts/RankBars.vue.d.ts +1 -1
  13. package/dist/components/charts/RankBars.vue.d.ts.map +1 -1
  14. package/dist/components/dataTable/useTableData.d.ts +1 -1
  15. package/dist/components/form/inputs/RadioGroup.vue.d.ts +1 -1
  16. package/dist/components/form/inputs/RadioGroup.vue.d.ts.map +1 -1
  17. package/dist/components/form/inputs/TelInput.vue.d.ts.map +1 -1
  18. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -1
  19. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  20. package/dist/components/kanban/KanbanBoard.vue.d.ts +10 -0
  21. package/dist/components/kanban/KanbanBoard.vue.d.ts.map +1 -1
  22. package/dist/components/layout/AppLayout.vue.d.ts +2 -2
  23. package/dist/components/layout/AppLayout.vue.d.ts.map +1 -1
  24. package/dist/composables/useDevice.d.ts +1 -1
  25. package/dist/core.css +1 -1
  26. package/dist/dialog/Dialog.vue.d.ts +3 -3
  27. package/dist/dialog/Dialog.vue.d.ts.map +1 -1
  28. package/dist/dialog/DialogConfirm.vue.d.ts +1 -1
  29. package/dist/dialog/DialogConfirm.vue.d.ts.map +1 -1
  30. package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
  31. package/dist/form-flow/custom-field-registry.d.ts +8 -0
  32. package/dist/form-flow/custom-field-registry.d.ts.map +1 -1
  33. package/dist/form-flow/form-flow.d.ts +1 -0
  34. package/dist/form-flow/form-flow.d.ts.map +1 -1
  35. package/dist/index.cjs +117 -109
  36. package/dist/index.css +1 -1
  37. package/dist/index.mjs +22882 -21361
  38. package/dist/style.css +1 -1
  39. package/dist/types/BagelForm.d.ts +2 -0
  40. package/dist/types/BagelForm.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/AccordionItem.vue +78 -22
  43. package/src/components/Btn.vue +17 -0
  44. package/src/components/ListItem.vue +6 -6
  45. package/src/components/RouterWrapper.vue +21 -1
  46. package/src/components/form/inputs/TelInput.vue +137 -121
  47. package/src/components/form/inputs/Upload/UploadInput.vue +33 -2
  48. package/src/components/form/inputs/Upload/upload.css +8 -0
  49. package/src/components/kanban/KanbanBoard.vue +7 -1
  50. package/src/form-flow/FormFlowPlayer.vue +33 -27
  51. package/src/form-flow/custom-field-registry.ts +9 -0
  52. package/src/form-flow/form-flow.ts +9 -1
  53. 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
- const phoneNumber = ref(props.modelValue ?? '')
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
- // Watch for changes to phoneNumber and emit update events
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 !== phoneNumber.value) {
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(c => c.toLowerCase())
59
- filteredCountries = filteredCountries.filter(c => onlyCountries.includes(c.iso2.toLowerCase()))
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 (activeCountry.value) {
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
- if (!phoneNumber.value) {
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.toLowerCase()
108
- return countries.value.find(c => c.iso2.toLowerCase() === code)
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
- // Parse and format the phone number
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 country code), skip validation
161
- if (!props.required) {
162
- const strippedValue = phoneNumber.value.replace(/[\s+]/g, '')
163
- const isJustDialCode = activeCountry.value && strippedValue === activeCountry.value.dialCode
164
- if (!phoneNumber.value || isJustDialCode) {
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 parsedNumber = parsePhoneNumberFromString(phoneNumber.value, getCountryCode())
173
- if (parsedNumber && parsedNumber.isValid()) {
174
- inputRef.value.setCustomValidity('')
175
- isValid.value = true
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 = false
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 (phoneNumber.value) {
204
- detectCountryFromNumber(phoneNumber.value)
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
- const { value } = input
224
- detectCountryFromNumber(value)
225
-
226
- if (value.startsWith('+')) {
227
- const formatted = parseAndFormatPhoneNumber(value)
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
- phoneNumber.value = value
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
- if (phoneNumber.value && !phoneNumber.value.startsWith('+') && activeCountry.value) {
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" v-model="phoneNumber" v-pattern.tel :required="required"
312
- :placeholder="underlined ? ' ' : computedPlaceholder" :disabled="disabled" type="tel" autocomplete="tel" :name="id"
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 isImage = (str: string) => IMAGE_FORMATS_REGEXP.test(str)
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
- <Loading v-if="col.loading" class="bgl-kanban-loading p-1 display-flex justify-content-center" />
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
- const proceed = await props.beforePrimary?.({
259
- data: { ...formData.value },
260
- currentStep: step!,
261
- isTerminal: isTerminal.value,
262
- })
263
- if (proceed === false) return
264
-
265
- if (isTerminal.value) {
266
- submitting.value = true
267
- try {
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
- finally {
288
- submitting.value = false
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
- // Advance to the resolved next step.
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) prop['x-field-type'] = field._type
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
@@ -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