@bagelink/vue 1.0.43 → 1.0.50

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.
@@ -0,0 +1,352 @@
1
+ <script setup lang="ts">
2
+ import type { Country } from '@bagelink/vue'
3
+ import type { CountryCode } from 'libphonenumber-js'
4
+ import { Dropdown, Flag, Icon, TextInput, allCountries } from '@bagelink/vue'
5
+ import axios from 'axios'
6
+ import { parsePhoneNumberFromString } from 'libphonenumber-js'
7
+ import { onMounted, watch, ref } from 'vue'
8
+
9
+ const props = defineProps<{
10
+ id?: string
11
+ label?: string
12
+ modelValue: string
13
+ placeholder?: string
14
+ excludeCountries?: string[]
15
+ onlyCountries?: string[]
16
+ required?: boolean
17
+ disabled?: boolean
18
+ }>()
19
+
20
+ const emit = defineEmits(['update:modelValue', 'blur', 'focus', 'keydown', 'input', 'paste'])
21
+
22
+ let phoneNumber = $ref(props.modelValue)
23
+ let search = $ref('')
24
+ let activeCountry = $ref<Country>()
25
+ const searchInput = $ref<InstanceType<typeof TextInput>>()
26
+ let open = $ref(false)
27
+ const inputRef = ref<HTMLInputElement | null>(null)
28
+ let isValid = $ref(true)
29
+
30
+ // Watch for changes to phoneNumber and emit update events
31
+ watch(() => phoneNumber, (newValue) => {
32
+ emit('update:modelValue', newValue)
33
+ })
34
+
35
+ // Watch for changes to the modelValue prop
36
+ watch(() => props.modelValue, (newValue) => {
37
+ if (newValue !== phoneNumber) {
38
+ phoneNumber = newValue
39
+ }
40
+ })
41
+
42
+ const countries = $computed(() => {
43
+ let filteredCountries = allCountries
44
+ if (props.excludeCountries && props.excludeCountries.length) {
45
+ const excludeCountries = props.excludeCountries.map(c => c.toLowerCase())
46
+ filteredCountries = filteredCountries.filter(c => !excludeCountries.includes(c.iso2.toLowerCase()))
47
+ }
48
+ if (props.onlyCountries && props.onlyCountries.length) {
49
+ const onlyCountries = props.onlyCountries.map(c => c.toLowerCase())
50
+ filteredCountries = filteredCountries.filter(c => onlyCountries.includes(c.iso2.toLowerCase()))
51
+ }
52
+ if (search.length) {
53
+ const lowerCaseSearch = search.toLowerCase()
54
+ filteredCountries = filteredCountries.filter(c => c.name.toLowerCase().includes(lowerCaseSearch)
55
+ || c.iso2.toLowerCase().includes(lowerCaseSearch)
56
+ || c.dialCode.includes(search)
57
+ )
58
+ }
59
+ return filteredCountries
60
+ })
61
+
62
+ const activeCountryCode = $computed(() => activeCountry?.iso2)
63
+
64
+ function selectCountry(country: Country) {
65
+ activeCountry = country
66
+ open = false
67
+ search = ''
68
+ if (!phoneNumber) phoneNumber = `+${activeCountry.dialCode}`
69
+ }
70
+
71
+ async function getIp() {
72
+ let apiData = sessionStorage.getItem('ipapi')
73
+ if (!apiData) {
74
+ apiData = (await axios.get('https://ipapi.co/json/')).data
75
+ apiData = JSON.stringify(apiData)
76
+ sessionStorage.setItem('ipapi', apiData)
77
+ }
78
+ const { country_code } = JSON.parse(apiData)
79
+ selectCountry(countries.find(c => c.iso2 === country_code) ?? countries[0])
80
+ }
81
+
82
+ // Get the country code for use with libphonenumber-js
83
+ function getCountryCode(): CountryCode | undefined {
84
+ return activeCountry?.iso2 ? activeCountry.iso2.toUpperCase() as CountryCode : undefined
85
+ }
86
+
87
+ // Parse and format the phone number
88
+ function parseAndFormatPhoneNumber(value: string): string {
89
+ if (!value) return value
90
+
91
+ try {
92
+ const parsedNumber = parsePhoneNumberFromString(value, getCountryCode())
93
+ if (parsedNumber && parsedNumber.isValid()) {
94
+ return parsedNumber.formatInternational()
95
+ }
96
+ } catch (error) {
97
+ console.error('Error parsing phone number:', error)
98
+ }
99
+ return value
100
+ }
101
+
102
+ // Validate the phone number and set custom validity
103
+ function validatePhoneNumber() {
104
+ if (!inputRef.value) return
105
+
106
+ try {
107
+ const parsedNumber = parsePhoneNumberFromString(phoneNumber, getCountryCode())
108
+ if (parsedNumber && parsedNumber.isValid()) {
109
+ inputRef.value.setCustomValidity('')
110
+ isValid = true
111
+ } else {
112
+ inputRef.value.setCustomValidity('Please enter a valid phone number')
113
+ isValid = false
114
+ }
115
+ } catch (error) {
116
+ inputRef.value.setCustomValidity('Please enter a valid phone number')
117
+ isValid = false
118
+ }
119
+ }
120
+
121
+ function detectCountryFromNumber(value: string): boolean {
122
+ if (!value.startsWith('+')) return false
123
+
124
+ const digits = value.replace(/\D/g, '')
125
+ if (digits.length <= 1) return false
126
+
127
+ for (const country of countries) {
128
+ if (digits.startsWith(country.dialCode) && country !== activeCountry) {
129
+ selectCountry(country)
130
+ return true
131
+ }
132
+ }
133
+ return false
134
+ }
135
+
136
+ async function initializeCountry() {
137
+ if (phoneNumber) {
138
+ detectCountryFromNumber(phoneNumber)
139
+ } else {
140
+ await getIp()
141
+ }
142
+ const formatted = parseAndFormatPhoneNumber(phoneNumber)
143
+ if (formatted !== phoneNumber) {
144
+ phoneNumber = formatted
145
+ emit('input', phoneNumber)
146
+ validatePhoneNumber()
147
+ }
148
+ validatePhoneNumber()
149
+ }
150
+
151
+ function handlePhoneInput(event: Event) {
152
+ const input = event.target as HTMLInputElement
153
+ const { value } = input
154
+ detectCountryFromNumber(value)
155
+
156
+ if (value.startsWith('+')) {
157
+ const formatted = parseAndFormatPhoneNumber(value)
158
+ if (formatted !== value) {
159
+ phoneNumber = formatted
160
+ emit('input', event)
161
+ validatePhoneNumber()
162
+ return
163
+ }
164
+ }
165
+
166
+ phoneNumber = value
167
+ emit('input', event)
168
+ validatePhoneNumber()
169
+ }
170
+
171
+ function handleBlur(event: Event) {
172
+ if (phoneNumber && !phoneNumber.startsWith('+') && activeCountry) {
173
+ const nationalNumber = phoneNumber.replace(/^0+/, '')
174
+ phoneNumber = `+${activeCountry.dialCode} ${nationalNumber}`
175
+ } else if (phoneNumber) {
176
+ phoneNumber = parseAndFormatPhoneNumber(phoneNumber)
177
+ }
178
+
179
+ validatePhoneNumber()
180
+ emit('blur', event)
181
+ }
182
+
183
+ const disableDropdown = $computed(() => countries.length === 1 && !search)
184
+ const searchable = $computed(() => countries.length > 7 || search)
185
+
186
+ function focusSearchInput() {
187
+ setTimeout(() => searchInput?.focus(), 100)
188
+ }
189
+
190
+ onMounted(initializeCountry)
191
+ </script>
192
+
193
+ <template>
194
+ <div class="bagel-input text-input" :class="{ invalid: !isValid }">
195
+ <label>
196
+ {{ label }}
197
+ <div
198
+ dir="ltr"
199
+ class="flex gap-05 tel-input"
200
+ tabindex="-1"
201
+ aria-label="Country Code Selector"
202
+ aria-haspopup="listbox"
203
+ :aria-expanded="open"
204
+ >
205
+ <Dropdown
206
+ v-model:shown="open"
207
+ placement="bottom-start"
208
+ :disabled="disableDropdown"
209
+ @show="focusSearchInput"
210
+ >
211
+ <template #trigger>
212
+ <span class="flex gap-05 country-code-display">
213
+ <Icon v-if="!disableDropdown" :icon="open ? 'collapse_all' : 'expand_all'" />
214
+ <Flag v-if="activeCountryCode" :country="activeCountryCode" />
215
+ </span>
216
+ </template>
217
+ <div class="p-075 tel-countryp-dropdown">
218
+ <TextInput
219
+ v-if="searchable"
220
+ ref="searchInput"
221
+ v-model="search"
222
+ aria-label="Search by country name or country code"
223
+ placeholder="Search"
224
+ icon="search"
225
+ />
226
+
227
+ <ul
228
+ class="overflow-y p-0 max-h-300px"
229
+ role="listbox"
230
+ >
231
+ <li
232
+ v-for="(pb) in countries"
233
+ :key="pb.iso2"
234
+ role="option"
235
+ class="flex gap-075 pointer hover"
236
+ tabindex="-1"
237
+ :aria-selected="activeCountryCode === pb.iso2"
238
+ @click="selectCountry(pb)"
239
+ >
240
+ <Flag :country="pb.iso2" />
241
+ <p class="tel-country">{{ pb.name }}</p>
242
+ <span>
243
+ +{{ pb.dialCode }}
244
+ </span>
245
+ </li>
246
+ </ul>
247
+ </div>
248
+ </Dropdown>
249
+ <input
250
+ :id="id"
251
+ ref="inputRef"
252
+ v-model="phoneNumber"
253
+ v-pattern.tel
254
+ :required="required"
255
+ :placeholder="placeholder || label || 'Phone Number'"
256
+ :disabled="disabled"
257
+ type="tel"
258
+ autocomplete="tel"
259
+ :name="id"
260
+ tabindex="0"
261
+ class="national-number-input"
262
+ @blur="handleBlur($event)"
263
+ @focus="emit('focus', $event)"
264
+ @keydown="emit('keydown', $event)"
265
+ @input="handlePhoneInput($event)"
266
+ @paste="emit('paste', $event)"
267
+ >
268
+ </div>
269
+ </label>
270
+ </div>
271
+ </template>
272
+
273
+ <style scoped>
274
+ .tel-input {
275
+ direction: ltr;
276
+ text-align: left;
277
+ background: var(--input-bg);
278
+ border: none;
279
+ padding-inline-start: 0.7rem;
280
+ border-radius: var(--input-border-radius);
281
+ color: var(--input-color);
282
+ min-width: calc(var(--input-height) * 3);
283
+ width: 100%;
284
+ display: flex;
285
+ align-items: center;
286
+ }
287
+
288
+ .tel-input:focus-within {
289
+ outline: none;
290
+ box-shadow: inset 0 0 10px #00000012;
291
+ }
292
+
293
+ .tel-input input {
294
+ background: transparent;
295
+ text-align: left;
296
+ flex: 1;
297
+ }
298
+
299
+ .tel-input input:focus-visible {
300
+ box-shadow: none;
301
+ }
302
+
303
+ .country-code-display {
304
+ align-items: center;
305
+ white-space: nowrap;
306
+ }
307
+
308
+ .dial-code {
309
+ font-size: var(--input-font-size);
310
+ color: var(--input-color);
311
+ opacity: 0.6;
312
+ }
313
+
314
+ .tel-country {
315
+ font-size: var(--input-font-size);
316
+ max-width: 200px;
317
+ white-space: nowrap;
318
+ text-overflow: ellipsis;
319
+ overflow: hidden;
320
+ margin-top: 0;
321
+ margin-bottom: 0;
322
+ }
323
+
324
+ .tel-countryp-dropdown {
325
+ direction: ltr;
326
+ text-align: left;
327
+ }
328
+
329
+ .national-number-input {
330
+ margin-left: 4px;
331
+ }
332
+
333
+ .country-changed {
334
+ animation: highlight-country 1.5s ease-in-out;
335
+ }
336
+
337
+ .invalid input {
338
+ border-color: var(--error-color, red);
339
+ }
340
+
341
+ @keyframes highlight-country {
342
+ 0%, 100% {
343
+ background-color: transparent;
344
+ }
345
+ 30% {
346
+ background-color: var(--primary-color-light, rgba(0, 123, 255, 0.2));
347
+ }
348
+ 70% {
349
+ background-color: var(--primary-color-light, rgba(0, 123, 255, 0.2));
350
+ }
351
+ }
352
+ </style>
@@ -10,6 +10,7 @@ export { default as JSONInput } from './JSONInput.vue'
10
10
  export { default as NumberInput } from './NumberInput.vue'
11
11
  export { default as OTP } from './OTP.vue'
12
12
  export { default as PasswordInput } from './PasswordInput.vue'
13
+ export { default as TelInput } from './PhoneInput.vue'
13
14
  export { default as RadioGroup } from './RadioGroup.vue'
14
15
  export { default as RadioPillsInput } from './RadioPillsInput.vue'
15
16
  export { default as RangeInput } from './RangeInput.vue'
@@ -17,7 +18,6 @@ export { default as RichText } from './RichText/index.vue'
17
18
  export { default as SelectInput } from './SelectInput.vue'
18
19
  export { default as SignaturePad } from './SignaturePad.vue'
19
20
  export { default as TableField } from './TableField.vue'
20
- export { default as TelInput } from './TelInput.vue'
21
21
  export { default as TextInput } from './TextInput.vue'
22
22
  export { default as ToggleInput } from './ToggleInput.vue'
23
23
  export { default as UploadInput } from './Upload/UploadInput.vue'
@@ -4,29 +4,22 @@ import type { Ref, UnwrapRef } from 'vue'
4
4
  import { getFallbackSchema } from '@bagelink/vue'
5
5
  import { ref, watch } from 'vue'
6
6
 
7
- interface useBglSchemaParamsT<T> {
7
+
8
+
9
+
10
+ interface UseBglSchemaParamsT<T> {
8
11
  schema?: BglFormSchemaFnT<T>
9
12
  columns?: string[]
10
13
  data?: any[]
11
14
  }
12
15
 
13
- export async function useBglSchema<T = { [key: string]: unknown }>(
14
- { schema, columns, data }: useBglSchemaParamsT<T> = {}
15
- ): Promise<BglFormSchemaT<T>> {
16
+ export function useBglSchema<T = { [key: string]: unknown }>(
17
+ { schema, columns, data }: UseBglSchemaParamsT<T> = {}
18
+ ): BglFormSchemaT<T> {
16
19
  let _schema = schema
17
-
18
- // Handle async schema functions
19
20
  if (typeof _schema === 'function') {
20
- const result = _schema()
21
- if (result instanceof Promise) {
22
- _schema = await result
23
- } else {
24
- _schema = result
25
- }
26
- } else if (_schema instanceof Promise) {
27
- _schema = await _schema
21
+ _schema = _schema()
28
22
  }
29
-
30
23
  if (_schema) {
31
24
  return (
32
25
  columns && columns.length > 0
@@ -9,6 +9,7 @@ const DEFAULT_PATTERNS: Record<string, PatternValue> = {
9
9
  pint: { pattern: /\d/ },
10
10
  int: { pattern: /[\d-]/ },
11
11
  pnum: { pattern: /[\d.]/ },
12
+ tel: { pattern: /[\d.\-()\s+]/ },
12
13
  money: { pattern: /[\d.\s,]/ },
13
14
  number: { pattern: /[\d\-.]/ },
14
15
  num: { pattern: /[\d\-.]/ },