@bagelink/vue 1.6.36 → 1.6.39
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/Modal.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/EmailInput.vue.d.ts.map +1 -1
- package/dist/dist-76Ukdba8.js +713 -0
- package/dist/dist-BeGE8xoH.cjs +1 -0
- package/dist/dist-RFX0YUEq.cjs +1 -0
- package/dist/dist-XUySYNnc.js +2 -0
- package/dist/index.cjs +24 -24
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +4951 -4885
- package/dist/plugins/useModal.d.ts +1 -0
- package/dist/plugins/useModal.d.ts.map +1 -1
- package/dist/plugins/useToast.d.ts +15 -0
- package/dist/plugins/useToast.d.ts.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +8 -3
- package/src/components/Modal.vue +10 -22
- package/src/components/form/inputs/EmailInput.vue +30 -49
- package/src/composables/index.ts +2 -2
- package/src/index.ts +1 -0
- package/src/plugins/useModal.ts +108 -17
- package/src/plugins/useToast.ts +92 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bagelink/vue",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.6.
|
|
4
|
+
"version": "1.6.39",
|
|
5
5
|
"description": "Bagel core sdk packages",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bagel Studio",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"@types/signature_pad": "^4.0.0",
|
|
63
63
|
"@vue-macros/reactivity-transform": "^1.1.6",
|
|
64
64
|
"vue": "^3.5.16",
|
|
65
|
-
"vue-component-type-helpers": "^2.2.10"
|
|
65
|
+
"vue-component-type-helpers": "^2.2.10",
|
|
66
|
+
"vue-toastification": "^2.0.0-rc.5"
|
|
66
67
|
},
|
|
67
68
|
"peerDependencies": {
|
|
68
69
|
"@bagelink/sdk": "*",
|
|
@@ -70,11 +71,15 @@
|
|
|
70
71
|
"vue-component-type-helpers": "^2.2.10",
|
|
71
72
|
"vue": "*",
|
|
72
73
|
"vue-draggable-next": "^2.2.1",
|
|
73
|
-
"vue-router": "*"
|
|
74
|
+
"vue-router": "*",
|
|
75
|
+
"vue-toastification": "^2"
|
|
74
76
|
},
|
|
75
77
|
"peerDependenciesMeta": {
|
|
76
78
|
"vue-draggable-next": {
|
|
77
79
|
"optional": true
|
|
80
|
+
},
|
|
81
|
+
"vue-toastification": {
|
|
82
|
+
"optional": true
|
|
78
83
|
}
|
|
79
84
|
},
|
|
80
85
|
"publishConfig": {
|
package/src/components/Modal.vue
CHANGED
|
@@ -7,8 +7,6 @@ import {
|
|
|
7
7
|
Title
|
|
8
8
|
} from '@bagelink/vue'
|
|
9
9
|
import {
|
|
10
|
-
onMounted,
|
|
11
|
-
onUnmounted,
|
|
12
10
|
useSlots,
|
|
13
11
|
watch
|
|
14
12
|
} from 'vue'
|
|
@@ -43,27 +41,27 @@ let isVisible = $ref<boolean>(false)
|
|
|
43
41
|
watch(
|
|
44
42
|
() => props.visible,
|
|
45
43
|
(val) => {
|
|
46
|
-
if (val === isVisible || val === undefined) {return}
|
|
47
|
-
if (val) {openModal()}
|
|
48
|
-
else {closeModal()}
|
|
44
|
+
if (val === isVisible || val === undefined) { return }
|
|
45
|
+
if (val) { openModal() }
|
|
46
|
+
else { closeModal() }
|
|
49
47
|
},
|
|
50
48
|
{ immediate: true },
|
|
51
49
|
)
|
|
52
50
|
|
|
53
51
|
const maxWidth = $computed(() => {
|
|
54
52
|
const { width } = props
|
|
55
|
-
if (width?.match(/px|em|rem|vw|vh|%/)) {return { 'max-width': width }}
|
|
56
|
-
if (width?.match(/\d+/)) {return { 'max-width': `${width}px` }}
|
|
53
|
+
if (width?.match(/px|em|rem|vw|vh|%/)) { return { 'max-width': width } }
|
|
54
|
+
if (width?.match(/\d+/)) { return { 'max-width': `${width}px` } }
|
|
57
55
|
return { 'max-width': '720px' }
|
|
58
56
|
})
|
|
59
57
|
|
|
60
58
|
// Computed properties for close button placement
|
|
61
|
-
const isOverlay = $computed(() => 'overlay'
|
|
62
|
-
const isHeader = $computed(() => 'header'
|
|
63
|
-
const isFooter = $computed(() => 'footer'
|
|
59
|
+
const isOverlay = $computed(() => props.closePlacement === 'overlay' || props.closePlacement === 'overlay-end')
|
|
60
|
+
const isHeader = $computed(() => props.closePlacement === 'header' || props.closePlacement === 'header-end')
|
|
61
|
+
const isFooter = $computed(() => props.closePlacement === 'footer')
|
|
64
62
|
|
|
65
63
|
const overlayCloseClass = $computed(() => {
|
|
66
|
-
if ('overlay-end'
|
|
64
|
+
if (props.closePlacement === 'overlay-end') { return 'top-1 end-1' }
|
|
67
65
|
return 'top-1 start-1'
|
|
68
66
|
})
|
|
69
67
|
|
|
@@ -74,21 +72,11 @@ function closeModal() {
|
|
|
74
72
|
|
|
75
73
|
defineExpose({ closeModal })
|
|
76
74
|
|
|
77
|
-
function escapeKeyClose(e: KeyboardEvent) {
|
|
78
|
-
if (props.dismissable && 'Escape' === e.key) {
|
|
79
|
-
closeModal()
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
75
|
function openModal() {
|
|
84
76
|
setTimeout(() => (isVisible = true), 1)
|
|
85
77
|
}
|
|
86
78
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
onUnmounted(() => {
|
|
90
|
-
document.removeEventListener('keydown', escapeKeyClose)
|
|
91
|
-
})
|
|
79
|
+
// Note: ESC key handling is now done centrally in ModalPlugin for proper stacking behavior
|
|
92
80
|
</script>
|
|
93
81
|
|
|
94
82
|
<template>
|
|
@@ -103,7 +103,7 @@ const input = $ref<HTMLInputElement>()
|
|
|
103
103
|
|
|
104
104
|
// Use custom validation function
|
|
105
105
|
function validateEmail(value: string) {
|
|
106
|
-
if (!value) {return}
|
|
106
|
+
if (!value) { return }
|
|
107
107
|
|
|
108
108
|
// Basic format validation
|
|
109
109
|
if (!EMAIL_REGEX.test(value)) {
|
|
@@ -133,19 +133,19 @@ const debouncedEmit = useDebounceFn(() => { emit('debounce', inputVal) }, 700)
|
|
|
133
133
|
|
|
134
134
|
// Validate input directly when value changes
|
|
135
135
|
function validateInput() {
|
|
136
|
-
if (!input) {return}
|
|
136
|
+
if (!input) { return }
|
|
137
137
|
|
|
138
138
|
input.setCustomValidity('')
|
|
139
|
-
if (!inputVal) {return}
|
|
139
|
+
if (!inputVal) { return }
|
|
140
140
|
const validationResult = validateEmail(inputVal)
|
|
141
|
-
if ('string'
|
|
141
|
+
if (typeof validationResult === 'string') {
|
|
142
142
|
input.setCustomValidity(validationResult)
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Perform server validation of email
|
|
147
147
|
async function validateEmailWithServer(email: string) {
|
|
148
|
-
if (!props.serverValidate || !email || !EMAIL_REGEX.test(email)) {return}
|
|
148
|
+
if (!props.serverValidate || !email || !EMAIL_REGEX.test(email)) { return }
|
|
149
149
|
|
|
150
150
|
// If we've already validated this email, use cached result
|
|
151
151
|
if (validatedEmails.has(email)) {
|
|
@@ -163,10 +163,10 @@ async function validateEmailWithServer(email: string) {
|
|
|
163
163
|
headers: { 'Content-Type': 'application/json' }
|
|
164
164
|
})
|
|
165
165
|
|
|
166
|
-
if (!response.ok) {throw new Error('Validation service unavailable')}
|
|
166
|
+
if (!response.ok) { throw new Error('Validation service unavailable') }
|
|
167
167
|
|
|
168
168
|
const result = await response.json()
|
|
169
|
-
const isValid = 'valid'
|
|
169
|
+
const isValid = result.status === 'valid' || result.has_mx === true
|
|
170
170
|
|
|
171
171
|
isValidEmail.value = isValid
|
|
172
172
|
validatedEmails.set(email, isValid)
|
|
@@ -186,7 +186,7 @@ async function validateEmailWithServer(email: string) {
|
|
|
186
186
|
|
|
187
187
|
// Check for email typos and suggest corrections
|
|
188
188
|
function checkForTypos(email: string) {
|
|
189
|
-
if (!props.autocorrect || !email) {return}
|
|
189
|
+
if (!props.autocorrect || !email) { return }
|
|
190
190
|
|
|
191
191
|
// Handle case where domain is incomplete (missing TLD)
|
|
192
192
|
if (email.includes('@') && !email.includes('.')) {
|
|
@@ -203,7 +203,7 @@ function checkForTypos(email: string) {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
// Standard typo checking for complete emails
|
|
206
|
-
if (!email.includes('@')) {return}
|
|
206
|
+
if (!email.includes('@')) { return }
|
|
207
207
|
|
|
208
208
|
const [username, domain] = email.split('@')
|
|
209
209
|
const domainLower = domain.toLowerCase()
|
|
@@ -216,7 +216,7 @@ function checkForTypos(email: string) {
|
|
|
216
216
|
|
|
217
217
|
// Find close matches using Levenshtein distance
|
|
218
218
|
for (const commonDomain of COMMON_EMAIL_DOMAINS) {
|
|
219
|
-
if (
|
|
219
|
+
if (calculateLevenshteinDistance(domainLower, commonDomain) <= 2) {
|
|
220
220
|
suggestedCorrection.value = `${username}@${commonDomain}`.toLowerCase()
|
|
221
221
|
return
|
|
222
222
|
}
|
|
@@ -265,7 +265,7 @@ function calculateLevenshteinDistance(a: string, b: string): number {
|
|
|
265
265
|
const debouncedServerValidate = useDebounceFn(() => validateEmailWithServer(inputVal), 1000)
|
|
266
266
|
|
|
267
267
|
function updateInputVal() {
|
|
268
|
-
if (props.disabled) {return}
|
|
268
|
+
if (props.disabled) { return }
|
|
269
269
|
|
|
270
270
|
// Remove typo checking while typing - only do this on focusout now
|
|
271
271
|
// checkForTypos(inputVal)
|
|
@@ -293,7 +293,7 @@ function handleFocusout(e: FocusEvent) {
|
|
|
293
293
|
validateEmailWithServer(inputVal)
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
if (props.onFocusout) {props.onFocusout(e)}
|
|
296
|
+
if (props.onFocusout) { props.onFocusout(e) }
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
watch(
|
|
@@ -318,30 +318,27 @@ const focus = () => input?.focus()
|
|
|
318
318
|
defineExpose({ focus, hasFocus })
|
|
319
319
|
|
|
320
320
|
onMounted(() => {
|
|
321
|
-
if (props.autofocus) {setTimeout(() => input?.focus(), 10)}
|
|
321
|
+
if (props.autofocus) { setTimeout(() => input?.focus(), 10) }
|
|
322
322
|
// Don't auto-restore defaultValue - let user control their own content
|
|
323
323
|
})
|
|
324
324
|
</script>
|
|
325
325
|
|
|
326
326
|
<template>
|
|
327
327
|
<div
|
|
328
|
-
class="bagel-input text-input"
|
|
329
|
-
:class="{
|
|
328
|
+
class="bagel-input text-input" :class="{
|
|
330
329
|
small,
|
|
331
330
|
shrink,
|
|
332
331
|
'textInputIconWrap': icon,
|
|
333
332
|
'txtInputIconStart': iconStart,
|
|
334
333
|
'is-validating': isValidating,
|
|
335
|
-
}"
|
|
336
|
-
:title="title"
|
|
334
|
+
}" :title="title"
|
|
337
335
|
>
|
|
338
336
|
<label :for="id">
|
|
339
337
|
<div class="flex">
|
|
340
338
|
{{ label }} <span v-if="required">*</span>
|
|
341
339
|
<span v-if="helptext" class="opacity-7 light">{{ helptext }}</span>
|
|
342
340
|
<span
|
|
343
|
-
v-if="suggestedCorrection"
|
|
344
|
-
class="pointer nowrap inline-block ms-auto color-red txt-10px p-0"
|
|
341
|
+
v-if="suggestedCorrection" class="pointer nowrap inline-block ms-auto color-red txt-10px p-0"
|
|
345
342
|
@click.prevent="applyCorrection"
|
|
346
343
|
>
|
|
347
344
|
did you mean {{ suggestedCorrection }}?
|
|
@@ -349,32 +346,12 @@ onMounted(() => {
|
|
|
349
346
|
<span v-if="isValidating" class="validating">Validating email...</span>
|
|
350
347
|
</div>
|
|
351
348
|
<input
|
|
352
|
-
:id
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
v-pattern:lower
|
|
356
|
-
class="ltr"
|
|
357
|
-
:name
|
|
358
|
-
:title
|
|
359
|
-
autocomplete="email"
|
|
360
|
-
type="email"
|
|
361
|
-
:placeholder="placeholder || label"
|
|
362
|
-
:disabled
|
|
363
|
-
:required
|
|
364
|
-
v-bind="nativeInputAttrs"
|
|
365
|
-
@focusout="handleFocusout"
|
|
366
|
-
@focus="onFocus"
|
|
367
|
-
@input="updateInputVal"
|
|
349
|
+
:id ref="input" v-model="inputVal" v-pattern:lower class="ltr" :name :title autocomplete="email"
|
|
350
|
+
type="email" :placeholder="placeholder || label" :disabled :required v-bind="nativeInputAttrs"
|
|
351
|
+
@focusout="handleFocusout" @focus="onFocus" @input="updateInputVal"
|
|
368
352
|
>
|
|
369
|
-
<Icon
|
|
370
|
-
|
|
371
|
-
class="iconStart"
|
|
372
|
-
:icon="iconStart"
|
|
373
|
-
/>
|
|
374
|
-
<Icon
|
|
375
|
-
v-if="icon"
|
|
376
|
-
:icon="icon"
|
|
377
|
-
/>
|
|
353
|
+
<Icon v-if="iconStart" class="iconStart" :icon="iconStart" />
|
|
354
|
+
<Icon v-if="icon" :icon="icon" />
|
|
378
355
|
</label>
|
|
379
356
|
</div>
|
|
380
357
|
</template>
|
|
@@ -406,6 +383,7 @@ onMounted(() => {
|
|
|
406
383
|
background: var(--bgl-code-bg) !important;
|
|
407
384
|
color: var(--bgl-light-text) !important;
|
|
408
385
|
}
|
|
386
|
+
|
|
409
387
|
.code textarea::placeholder {
|
|
410
388
|
color: var(--bgl-light-text) !important;
|
|
411
389
|
opacity: 0.3;
|
|
@@ -433,22 +411,25 @@ onMounted(() => {
|
|
|
433
411
|
.textInputIconWrap .bgl_icon-font {
|
|
434
412
|
color: var(--input-color);
|
|
435
413
|
position: absolute;
|
|
436
|
-
|
|
414
|
+
inset-inline-end: calc(var(--input-height) / 3 - 0.25rem);
|
|
437
415
|
margin-top: calc(var(--input-height) / 2 + 0.1rem);
|
|
438
416
|
line-height: 0;
|
|
439
417
|
}
|
|
440
|
-
|
|
418
|
+
|
|
419
|
+
.textInputIconWrap input {
|
|
441
420
|
padding-inline-end: calc(var(--input-height) / 3 + 1.5rem);
|
|
442
421
|
}
|
|
443
422
|
|
|
444
423
|
.txtInputIconStart .iconStart {
|
|
445
424
|
color: var(--input-color);
|
|
446
425
|
position: absolute;
|
|
447
|
-
|
|
448
|
-
margin-top: calc(var(--input-height) / 2
|
|
426
|
+
inset-inline-start: calc(var(--input-height) / 3 - 0.25rem);
|
|
427
|
+
margin-top: calc(var(--input-height) / 2);
|
|
449
428
|
line-height: 0;
|
|
450
429
|
}
|
|
451
|
-
|
|
430
|
+
|
|
431
|
+
.txtInputIconStart input,
|
|
432
|
+
.txtInputIconStart textarea {
|
|
452
433
|
padding-inline-start: calc(var(--input-height) / 3 + 1.5rem);
|
|
453
434
|
}
|
|
454
435
|
|
package/src/composables/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function useBglSchema<T = { [key: string]: unknown }>(
|
|
|
24
24
|
|
|
25
25
|
if (_schema) {
|
|
26
26
|
return (
|
|
27
|
-
_columns &&
|
|
27
|
+
_columns && _columns.length > 0
|
|
28
28
|
? _schema.filter(f => _columns.includes(f.id as string))
|
|
29
29
|
: _schema
|
|
30
30
|
) as BglFormSchemaT<T>
|
|
@@ -39,7 +39,7 @@ export function localRef<T>(
|
|
|
39
39
|
IfAny<T, Ref<T, T>, T> :
|
|
40
40
|
Ref<UnwrapRef<T>, T | UnwrapRef<T>> {
|
|
41
41
|
const storedValue = localStorage.getItem(key)
|
|
42
|
-
const initial =
|
|
42
|
+
const initial = storedValue !== null ? JSON.parse(storedValue) : initialValue
|
|
43
43
|
const value = ref<T>(initial)
|
|
44
44
|
|
|
45
45
|
watch(() => value.value, (val) => {
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
injectI18nT,
|
|
10
10
|
} from './plugins/bagel'
|
|
11
11
|
export { ModalPlugin, useModal } from './plugins/useModal'
|
|
12
|
+
export { type BagelToastOptions, type ToastApi, ToastPlugin, useToast } from './plugins/useToast'
|
|
12
13
|
export * from './types'
|
|
13
14
|
export * from './utils'
|
|
14
15
|
export * from './utils/allCountries'
|
package/src/plugins/useModal.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { InjectionKey, Plugin } from 'vue'
|
|
1
|
+
import type { App, InjectionKey, Plugin } from 'vue'
|
|
2
2
|
import type { ComponentProps } from 'vue-component-type-helpers'
|
|
3
3
|
import type {
|
|
4
4
|
ModalComponentProps,
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
ConfirmModalUserOptions,
|
|
10
10
|
ModalConfirmOptions
|
|
11
11
|
} from './modalTypes'
|
|
12
|
-
import { defineComponent, h, inject, reactive } from 'vue'
|
|
12
|
+
import { createApp, defineComponent, h, inject, reactive } from 'vue'
|
|
13
13
|
import { Modal, ModalConfirm, ModalForm } from '../components'
|
|
14
14
|
|
|
15
15
|
export interface ModalApi {
|
|
@@ -23,29 +23,49 @@ export interface ModalApi {
|
|
|
23
23
|
) => ModalFormComponentProps<T> | undefined
|
|
24
24
|
hideModal: (index?: number) => void
|
|
25
25
|
confirmModal: (options: ConfirmModalUserOptions) => Promise<boolean>
|
|
26
|
+
hideAllModals: () => void
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export const ModalSymbol: InjectionKey<ModalApi> = Symbol('modal')
|
|
29
30
|
|
|
31
|
+
// Global singleton instance as fallback (created on first plugin install)
|
|
32
|
+
let globalModalApi: ModalApi | null = null
|
|
33
|
+
|
|
30
34
|
export function useModal(): ModalApi {
|
|
31
|
-
const modalApi = inject(ModalSymbol)
|
|
32
|
-
if (!modalApi) {
|
|
35
|
+
const modalApi = inject(ModalSymbol, null)
|
|
36
|
+
if (!modalApi) {
|
|
37
|
+
// Fallback to global singleton if injection fails
|
|
38
|
+
if (globalModalApi) {
|
|
39
|
+
return globalModalApi
|
|
40
|
+
}
|
|
41
|
+
throw new Error('Modal API not provided. Make sure ModalPlugin is installed via app.use(ModalPlugin)')
|
|
42
|
+
}
|
|
33
43
|
return modalApi
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
export const ModalPlugin: Plugin = {
|
|
37
|
-
install: (app) => {
|
|
47
|
+
install: (app: App) => {
|
|
48
|
+
const BASE_Z_INDEX = 1000
|
|
38
49
|
const modalStack = reactive<ModalComponentProps<object>[]>([])
|
|
39
50
|
|
|
40
|
-
const hideModal = (index
|
|
41
|
-
|
|
51
|
+
const hideModal = (index?: number) => {
|
|
52
|
+
if (index === undefined || index >= modalStack.length - 1) {
|
|
53
|
+
// Close top modal
|
|
54
|
+
modalStack.splice(modalStack.length - 1, 1)
|
|
55
|
+
} else {
|
|
56
|
+
modalStack.splice(index, 1)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hideAllModals = () => {
|
|
61
|
+
modalStack.splice(0, modalStack.length)
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
const confirmModal = (options: ConfirmModalUserOptions): Promise<boolean> => {
|
|
45
65
|
return new Promise((resolve) => {
|
|
46
|
-
const confirmOptions = 'string'
|
|
66
|
+
const confirmOptions = typeof options === 'string' ? { title: '', message: options } : options
|
|
47
67
|
modalStack.push({
|
|
48
|
-
modalOptions: { ...confirmOptions, resolve },
|
|
68
|
+
modalOptions: reactive({ ...confirmOptions, resolve }),
|
|
49
69
|
modalType: 'confirmModal',
|
|
50
70
|
componentSlots: {},
|
|
51
71
|
})
|
|
@@ -57,20 +77,23 @@ export const ModalPlugin: Plugin = {
|
|
|
57
77
|
options: ModalOptions | ModalFormOptions<T>,
|
|
58
78
|
slots: { [key: string]: any } = {}
|
|
59
79
|
): ModalComponentProps<T> | ModalFormComponentProps<T> | undefined => {
|
|
80
|
+
// Make options reactive so updates propagate
|
|
81
|
+
const reactiveOptions = reactive(options) as ModalOptions | ModalFormOptions<T>
|
|
82
|
+
|
|
60
83
|
const modalComponent = {
|
|
61
|
-
modalOptions:
|
|
84
|
+
modalOptions: reactiveOptions,
|
|
62
85
|
modalType,
|
|
63
86
|
componentSlots: slots,
|
|
64
87
|
}
|
|
65
88
|
modalStack.push(modalComponent)
|
|
66
89
|
|
|
67
|
-
if ('modalForm'
|
|
90
|
+
if (modalType === 'modalForm') {
|
|
68
91
|
return modalComponent as ModalFormComponentProps<T>
|
|
69
92
|
}
|
|
70
93
|
return modalComponent
|
|
71
94
|
}
|
|
72
95
|
|
|
73
|
-
|
|
96
|
+
const api: ModalApi = {
|
|
74
97
|
showModal: (options: ModalOptions, slots?: { [key: string]: any }) => showModal('modal', options, slots),
|
|
75
98
|
|
|
76
99
|
showModalForm: <T extends { [key: string]: any }>(
|
|
@@ -80,16 +103,54 @@ export const ModalPlugin: Plugin = {
|
|
|
80
103
|
|
|
81
104
|
confirmModal: (options: ConfirmModalUserOptions) => confirmModal(options),
|
|
82
105
|
|
|
83
|
-
hideModal
|
|
84
|
-
|
|
106
|
+
hideModal,
|
|
107
|
+
|
|
108
|
+
hideAllModals,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Set global singleton on first install
|
|
112
|
+
if (!globalModalApi) {
|
|
113
|
+
globalModalApi = api
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
app.provide(ModalSymbol, api)
|
|
117
|
+
|
|
118
|
+
// Modal container component
|
|
119
|
+
const ModalContainerComponent = defineComponent({
|
|
120
|
+
name: 'ModalContainer',
|
|
121
|
+
setup() {
|
|
122
|
+
// Handle ESC key - only close top modal
|
|
123
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
124
|
+
if (e.key === 'Escape' && modalStack.length > 0) {
|
|
125
|
+
const topModal = modalStack[modalStack.length - 1]
|
|
126
|
+
// Check if top modal is dismissable
|
|
127
|
+
const modalOptions = topModal.modalOptions as ModalOptions
|
|
128
|
+
if (modalOptions.dismissable !== false) {
|
|
129
|
+
hideModal()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
85
133
|
|
|
86
|
-
|
|
87
|
-
|
|
134
|
+
return {
|
|
135
|
+
modalStack,
|
|
136
|
+
handleEscape,
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
mounted() {
|
|
140
|
+
document.addEventListener('keydown', this.handleEscape)
|
|
141
|
+
},
|
|
142
|
+
unmounted() {
|
|
143
|
+
document.removeEventListener('keydown', this.handleEscape)
|
|
144
|
+
},
|
|
88
145
|
render() {
|
|
89
146
|
return this.modalStack.map((modal, index) => {
|
|
147
|
+
// Calculate z-index based on stack position
|
|
148
|
+
const zIndex = BASE_Z_INDEX + index * 10
|
|
149
|
+
|
|
90
150
|
const props = {
|
|
91
151
|
...modal.modalOptions,
|
|
92
152
|
'visible': true,
|
|
153
|
+
'zIndex': zIndex,
|
|
93
154
|
'onUpdate:visible': () => { hideModal(index) },
|
|
94
155
|
}
|
|
95
156
|
|
|
@@ -104,6 +165,36 @@ export const ModalPlugin: Plugin = {
|
|
|
104
165
|
})
|
|
105
166
|
},
|
|
106
167
|
})
|
|
107
|
-
|
|
168
|
+
|
|
169
|
+
// Register the component so users can manually add it if needed
|
|
170
|
+
app.component('ModalContainer', ModalContainerComponent)
|
|
171
|
+
|
|
172
|
+
// Auto-mount modal container to document.body
|
|
173
|
+
if (typeof document !== 'undefined' && typeof window !== 'undefined') {
|
|
174
|
+
// Wait for app to be mounted before injecting
|
|
175
|
+
app.mixin({
|
|
176
|
+
mounted() {
|
|
177
|
+
// Only run once on the root component
|
|
178
|
+
if (this.$root === this) {
|
|
179
|
+
const existingContainer = document.getElementById('bagelink-modal-root')
|
|
180
|
+
if (!existingContainer) {
|
|
181
|
+
// Create mount point
|
|
182
|
+
const container = document.createElement('div')
|
|
183
|
+
container.id = 'bagelink-modal-root'
|
|
184
|
+
document.body.appendChild(container)
|
|
185
|
+
|
|
186
|
+
// Create a separate app instance for modals to avoid context issues
|
|
187
|
+
const modalApp = createApp(ModalContainerComponent)
|
|
188
|
+
|
|
189
|
+
// Share the same context/plugins
|
|
190
|
+
modalApp._context = app._context
|
|
191
|
+
|
|
192
|
+
// Mount it
|
|
193
|
+
modalApp.mount(container)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
108
199
|
},
|
|
109
200
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { App, InjectionKey, Plugin } from 'vue'
|
|
2
|
+
import { inject } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
type PluginOptions as ToastOptions,
|
|
5
|
+
type ToastInterface,
|
|
6
|
+
POSITION,
|
|
7
|
+
} from 'vue-toastification'
|
|
8
|
+
|
|
9
|
+
export interface ToastApi {
|
|
10
|
+
success: (message: string, options?: any) => void
|
|
11
|
+
error: (message: string, options?: any) => void
|
|
12
|
+
info: (message: string, options?: any) => void
|
|
13
|
+
warning: (message: string, options?: any) => void
|
|
14
|
+
show: (message: string, options?: any) => void
|
|
15
|
+
clear: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ToastSymbol: InjectionKey<ToastApi> = Symbol('toast')
|
|
19
|
+
|
|
20
|
+
// Global singleton instance as fallback (created on first plugin install)
|
|
21
|
+
let globalToastApi: ToastApi | null = null
|
|
22
|
+
|
|
23
|
+
export function useToast(): ToastApi {
|
|
24
|
+
const toastApi = inject(ToastSymbol, null)
|
|
25
|
+
if (!toastApi) {
|
|
26
|
+
// Fallback to global singleton if injection fails
|
|
27
|
+
if (globalToastApi) {
|
|
28
|
+
return globalToastApi
|
|
29
|
+
}
|
|
30
|
+
throw new Error('Toast API not provided. Make sure ToastPlugin is installed via app.use(ToastPlugin)')
|
|
31
|
+
}
|
|
32
|
+
return toastApi
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type BagelToastOptions = Partial<ToastOptions>
|
|
36
|
+
|
|
37
|
+
export const ToastPlugin: Plugin<BagelToastOptions[]> = {
|
|
38
|
+
install: async (app: App, options: BagelToastOptions = {}) => {
|
|
39
|
+
try {
|
|
40
|
+
// Dynamically import vue-toastification to avoid bundling if not used
|
|
41
|
+
const Toast = await import('vue-toastification')
|
|
42
|
+
const ToastInterface = Toast.default || Toast
|
|
43
|
+
|
|
44
|
+
const defaultOptions: ToastOptions = {
|
|
45
|
+
position: POSITION.TOP_RIGHT,
|
|
46
|
+
timeout: 3000,
|
|
47
|
+
closeOnClick: true,
|
|
48
|
+
pauseOnFocusLoss: true,
|
|
49
|
+
pauseOnHover: true,
|
|
50
|
+
draggable: true,
|
|
51
|
+
draggablePercent: 0.6,
|
|
52
|
+
showCloseButtonOnHover: false,
|
|
53
|
+
hideProgressBar: false,
|
|
54
|
+
closeButton: 'button',
|
|
55
|
+
icon: true,
|
|
56
|
+
rtl: false,
|
|
57
|
+
transition: 'Vue-Toastification__fade',
|
|
58
|
+
maxToasts: 5,
|
|
59
|
+
newestOnTop: true,
|
|
60
|
+
...options,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Install vue-toastification
|
|
64
|
+
app.use(ToastInterface as Plugin, defaultOptions)
|
|
65
|
+
|
|
66
|
+
// Get the toast instance
|
|
67
|
+
const toast: ToastInterface = app.config.globalProperties.$toast
|
|
68
|
+
|
|
69
|
+
// Create the API wrapper
|
|
70
|
+
const api: ToastApi = {
|
|
71
|
+
success: (message: string, options?: any) => toast.success(message, options),
|
|
72
|
+
error: (message: string, options?: any) => toast.error(message, options),
|
|
73
|
+
info: (message: string, options?: any) => toast.info(message, options),
|
|
74
|
+
warning: (message: string, options?: any) => toast.warning(message, options),
|
|
75
|
+
show: (message: string, options?: any) => toast(message, options),
|
|
76
|
+
clear: () => { toast.clear() },
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Set global singleton on first install
|
|
80
|
+
if (!globalToastApi) {
|
|
81
|
+
globalToastApi = api
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
app.provide(ToastSymbol, api)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Failed to load vue-toastification. Make sure it is installed:', error)
|
|
87
|
+
throw new Error(
|
|
88
|
+
'vue-toastification is required for ToastPlugin. Install it with: pnpm add vue-toastification'
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|