@bagelink/vue 1.17.2 → 1.17.6
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/assetTransport.d.ts +9 -31
- package/dist/assetTransport.d.ts.map +1 -1
- package/dist/assets.d.ts +39 -20
- package/dist/assets.d.ts.map +1 -1
- package/dist/components/AddressSearch.vue.d.ts +40 -36
- package/dist/components/AddressSearch.vue.d.ts.map +1 -1
- package/dist/components/ContextMenu.vue.d.ts +518 -0
- package/dist/components/ContextMenu.vue.d.ts.map +1 -0
- package/dist/components/Dropdown.types.d.ts +25 -0
- package/dist/components/Dropdown.types.d.ts.map +1 -0
- package/dist/components/Dropdown.vue.d.ts +20 -15
- package/dist/components/Dropdown.vue.d.ts.map +1 -1
- package/dist/components/FilterQuery.vue.d.ts +1 -1
- package/dist/components/FilterQuery.vue.d.ts.map +1 -1
- package/dist/components/Icon/Icon.vue.d.ts +0 -1
- package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
- package/dist/components/Icon/constants.d.ts +0 -1
- package/dist/components/Icon/constants.d.ts.map +1 -1
- package/dist/components/Icon/types.d.ts +2 -3
- package/dist/components/Icon/types.d.ts.map +1 -1
- package/dist/components/Swiper.vue.d.ts +1 -1
- package/dist/components/draggable/useDraggable.d.ts.map +1 -1
- package/dist/components/form/inputs/IconPickerInput.vue.d.ts +48 -37
- package/dist/components/form/inputs/IconPickerInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/composables/useEditor.d.ts +3 -0
- package/dist/components/form/inputs/RichText/composables/useEditor.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts +1 -1
- package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/selection.d.ts +2 -0
- package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/table.d.ts +34 -8
- package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
- package/dist/components/form/inputs/SelectInput.vue.d.ts +71 -63
- package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/TelInput.vue.d.ts +2 -2
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/composables/index.d.ts +1 -1
- package/dist/composables/index.d.ts.map +1 -1
- package/dist/composables/useBoundedRectDrag.d.ts.map +1 -1
- package/dist/composables/usePointerSession.d.ts +14 -3
- package/dist/composables/usePointerSession.d.ts.map +1 -1
- package/dist/core.css +3 -3
- package/dist/directives/vResize.d.ts.map +1 -1
- package/dist/form-flow/form-flow.d.ts +4 -2
- package/dist/form-flow/form-flow.d.ts.map +1 -1
- package/dist/form-flow/schema-fields.d.ts +4 -1
- package/dist/form-flow/schema-fields.d.ts.map +1 -1
- package/dist/index.cjs +240 -153
- package/dist/index.css +1 -1
- package/dist/index.mjs +18459 -18281
- package/dist/style.css +3 -3
- package/dist/types/BagelForm.d.ts +2 -0
- package/dist/types/BagelForm.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/assetTransport.test.ts +33 -83
- package/src/assetTransport.ts +24 -90
- package/src/assets.test.ts +58 -99
- package/src/assets.ts +78 -161
- package/src/components/ContextMenu.vue +113 -0
- package/src/components/Dropdown.types.ts +27 -0
- package/src/components/Dropdown.vue +129 -64
- package/src/components/FilterQuery.vue +1 -1
- package/src/components/Icon/Icon.vue +8 -30
- package/src/components/Icon/constants.ts +0 -166
- package/src/components/Icon/types.ts +2 -3
- package/src/components/draggable/useDraggable.ts +67 -100
- package/src/components/form/inputs/IconPickerInput.vue +14 -9
- package/src/components/form/inputs/RichText/components/EditorToolbar.vue +43 -111
- package/src/components/form/inputs/RichText/composables/useCommands.ts +32 -54
- package/src/components/form/inputs/RichText/composables/useEditor.ts +45 -171
- package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +90 -14
- package/src/components/form/inputs/RichText/index.vue +1195 -1210
- package/src/components/form/inputs/RichText/utils/commands.ts +689 -1028
- package/src/components/form/inputs/RichText/utils/selection.ts +66 -41
- package/src/components/form/inputs/RichText/utils/table.ts +798 -136
- package/src/components/form/inputs/SelectInput.vue +1 -1
- package/src/components/form/inputs/Upload/AssetUploadInput.vue +2 -2
- package/src/components/index.ts +3 -1
- package/src/composables/index.ts +1 -1
- package/src/composables/useBoundedRectDrag.ts +9 -12
- package/src/composables/usePointerSession.ts +57 -12
- package/src/directives/vResize.ts +6 -7
- package/src/form-flow/form-flow.ts +8 -3
- package/src/form-flow/schema-fields.ts +9 -4
- package/src/styles/core.css +1 -18
- package/src/types/BagelForm.ts +2 -0
- package/dist/components/form/inputs/RichText/utils/formatting.d.ts +0 -10
- package/dist/components/form/inputs/RichText/utils/formatting.d.ts.map +0 -1
- package/src/components/form/inputs/RichText/components/gridBox.vue +0 -58
- package/src/components/form/inputs/RichText/utils/formatting.ts +0 -331
package/src/assets.ts
CHANGED
|
@@ -1,132 +1,75 @@
|
|
|
1
1
|
import { inject, onScopeDispose, ref, toValue, watch, type InjectionKey, type MaybeRefOrGetter } from 'vue'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Persist this reference, never a delivery URL. Assets are public-by-default;
|
|
5
|
+
* the id alone addresses the bytes. ``version_id`` is accepted for backward
|
|
6
|
+
* compatibility with callers that still emit it, but it is ignored: it no
|
|
7
|
+
* longer selects a version and never appears in a URL.
|
|
8
|
+
*/
|
|
4
9
|
export interface AssetReference {
|
|
5
10
|
asset_id: string
|
|
6
11
|
version_id?: string
|
|
7
12
|
}
|
|
8
13
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
export type AssetVisibility = 'protected' | 'public'
|
|
15
|
+
|
|
16
|
+
export interface AssetUploadOptions {
|
|
17
|
+
/** Protected is the safe default. Website/media callers must opt into public. */
|
|
18
|
+
visibility?: AssetVisibility
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
/** Display purpose → on-demand transform query params (mirrors common/blox). */
|
|
15
22
|
export type AssetPurpose = 'original' | 'preview' | 'thumbnail' | 'website' | 'social'
|
|
16
23
|
|
|
24
|
+
const PURPOSE_PARAMS: Record<AssetPurpose, string> = {
|
|
25
|
+
original: '',
|
|
26
|
+
preview: 'fmt=webp',
|
|
27
|
+
thumbnail: 'fmt=webp&w=256',
|
|
28
|
+
website: 'fmt=webp',
|
|
29
|
+
social: 'w=1200&h=630&fmt=jpeg&fit=cover',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A display intent: a pinned asset plus an optional purpose/preset. */
|
|
17
33
|
export interface AssetDeliveryRequest extends AssetReference {
|
|
18
|
-
/** Backend-approved preset, e.g. an image preview; never arbitrary transforms. */
|
|
19
34
|
representation?: AssetPurpose
|
|
20
35
|
}
|
|
21
36
|
|
|
22
|
-
/**
|
|
37
|
+
/** Separate facade: generated SDK namespaces remain unchanged. */
|
|
23
38
|
export interface AssetTransport {
|
|
24
|
-
|
|
25
|
-
resolve(references: readonly AssetDeliveryRequest[], signal: AbortSignal): Promise<AssetDelivery[]>
|
|
26
|
-
upload(file: File, signal: AbortSignal): Promise<AssetReference>
|
|
39
|
+
upload(file: File, signal: AbortSignal, options?: AssetUploadOptions): Promise<AssetReference>
|
|
27
40
|
}
|
|
28
41
|
|
|
29
|
-
interface
|
|
30
|
-
|
|
31
|
-
signal?: AbortSignal
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
export interface AssetClient {
|
|
43
|
+
/** Upload original bytes; protected unless the caller explicitly opts into public. */
|
|
44
|
+
upload(file: File, signal?: AbortSignal, options?: AssetUploadOptions): Promise<AssetReference>
|
|
45
|
+
/** Build the public CDN URL for a reference + optional purpose. */
|
|
46
|
+
assetUrl(reference: AssetReference, purpose?: AssetPurpose): string
|
|
47
|
+
/** Abort in-flight uploads (logout / tenant / account switch). */
|
|
48
|
+
clearSession(): void
|
|
49
|
+
/** Monotonic counter bumped on clearSession; watchers can subscribe. */
|
|
50
|
+
revision: number
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
const abortError = () => new DOMException('Asset request cancelled', 'AbortError')
|
|
37
54
|
|
|
38
|
-
/** App/session scoped
|
|
39
|
-
export function createAssetClient(
|
|
40
|
-
|
|
55
|
+
/** App/session scoped; no persistent storage or delivery cache (URLs are public). */
|
|
56
|
+
export function createAssetClient(
|
|
57
|
+
transport: AssetTransport,
|
|
58
|
+
deliveryBaseUrl: string,
|
|
59
|
+
): AssetClient {
|
|
41
60
|
const controllers = new Set<AbortController>()
|
|
42
|
-
let
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const batch = pending
|
|
49
|
-
pending = []
|
|
50
|
-
const active = batch.filter(item => !item.signal?.aborted)
|
|
51
|
-
for (const item of batch) if (item.signal?.aborted) item.reject(abortError())
|
|
52
|
-
if (!active.length) return
|
|
53
|
-
const controller = new AbortController()
|
|
54
|
-
controllers.add(controller)
|
|
55
|
-
const cancelIfUnused = () => {
|
|
56
|
-
for (const item of active) if (item.signal?.aborted) item.reject(abortError())
|
|
57
|
-
if (active.every(item => item.signal?.aborted)) controller.abort()
|
|
58
|
-
}
|
|
59
|
-
const rejectAborted = () => {
|
|
60
|
-
for (const item of active) item.reject(abortError())
|
|
61
|
-
}
|
|
62
|
-
controller.signal.addEventListener('abort', rejectAborted, { once: true })
|
|
63
|
-
for (const item of active) item.signal?.addEventListener('abort', cancelIfUnused)
|
|
64
|
-
try {
|
|
65
|
-
const deliveries = await transport.resolve(active.map(item => item.reference), controller.signal)
|
|
66
|
-
if (controller.signal.aborted) throw abortError()
|
|
67
|
-
if (deliveries.length !== active.length) throw new Error('Incomplete asset delivery batch')
|
|
68
|
-
active.forEach((item, index) => {
|
|
69
|
-
const delivery = deliveries[index]!
|
|
70
|
-
if (item.signal?.aborted) item.reject(abortError())
|
|
71
|
-
else if (!Number.isFinite(Date.parse(delivery.expires_at))
|
|
72
|
-
|| Date.parse(delivery.expires_at) <= Date.now()
|
|
73
|
-
|| !/^https?:\/\//.test(delivery.url)
|
|
74
|
-
|| !delivery.version_id
|
|
75
|
-
|| (item.reference.version_id && item.reference.version_id !== delivery.version_id)) {
|
|
76
|
-
item.reject(new Error('Invalid asset delivery'))
|
|
77
|
-
} else item.resolve(delivery)
|
|
78
|
-
})
|
|
79
|
-
} catch (error) {
|
|
80
|
-
for (const item of active) item.reject(error)
|
|
81
|
-
} finally {
|
|
82
|
-
controllers.delete(controller)
|
|
83
|
-
controller.signal.removeEventListener('abort', rejectAborted)
|
|
84
|
-
for (const item of active) item.signal?.removeEventListener('abort', cancelIfUnused)
|
|
85
|
-
}
|
|
61
|
+
let revision = 0
|
|
62
|
+
|
|
63
|
+
function assetUrl(reference: AssetReference, purpose: AssetPurpose = 'original'): string {
|
|
64
|
+
const query = PURPOSE_PARAMS[purpose] ?? ''
|
|
65
|
+
const base = deliveryBaseUrl.replace(/\/+$/, '')
|
|
66
|
+
return query ? `${base}/${reference.asset_id}?${query}` : `${base}/${reference.asset_id}`
|
|
86
67
|
}
|
|
87
68
|
|
|
88
69
|
return {
|
|
89
|
-
revision,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const key = JSON.stringify([reference.asset_id, reference.version_id ?? null, reference.representation ?? 'original'])
|
|
93
|
-
let entry = inFlight.get(key)
|
|
94
|
-
if (!entry) {
|
|
95
|
-
const controller = new AbortController()
|
|
96
|
-
const promise = new Promise<AssetDelivery>((resolve, reject) => {
|
|
97
|
-
pending.push({ reference: { ...reference }, signal: controller.signal, resolve, reject })
|
|
98
|
-
if (!scheduled) { scheduled = true; queueMicrotask(() => { void flush() }) }
|
|
99
|
-
})
|
|
100
|
-
entry = { controller, promise, users: 0 }
|
|
101
|
-
inFlight.set(key, entry)
|
|
102
|
-
const current = entry
|
|
103
|
-
void promise.then(() => {
|
|
104
|
-
if (inFlight.get(key) === current) inFlight.delete(key)
|
|
105
|
-
}, () => {
|
|
106
|
-
if (inFlight.get(key) === current) inFlight.delete(key)
|
|
107
|
-
})
|
|
108
|
-
}
|
|
109
|
-
const shared = entry
|
|
110
|
-
shared.users++
|
|
111
|
-
return new Promise((resolve, reject) => {
|
|
112
|
-
let settled = false
|
|
113
|
-
const finish = () => {
|
|
114
|
-
if (settled) return false
|
|
115
|
-
settled = true
|
|
116
|
-
signal?.removeEventListener('abort', abort)
|
|
117
|
-
shared.users--
|
|
118
|
-
if (!shared.users) {
|
|
119
|
-
if (inFlight.get(key) === shared) inFlight.delete(key)
|
|
120
|
-
shared.controller.abort()
|
|
121
|
-
}
|
|
122
|
-
return true
|
|
123
|
-
}
|
|
124
|
-
const abort = () => { if (finish()) reject(abortError()) }
|
|
125
|
-
signal?.addEventListener('abort', abort, { once: true })
|
|
126
|
-
void shared.promise.then(value => { if (finish()) resolve(value) }, error => { if (finish()) reject(error) })
|
|
127
|
-
})
|
|
128
|
-
},
|
|
129
|
-
async upload(file: File, signal?: AbortSignal): Promise<AssetReference> {
|
|
70
|
+
get revision() { return revision },
|
|
71
|
+
assetUrl,
|
|
72
|
+
async upload(file: File, signal?: AbortSignal, options?: AssetUploadOptions): Promise<AssetReference> {
|
|
130
73
|
const controller = new AbortController()
|
|
131
74
|
const abort = () => controller.abort()
|
|
132
75
|
signal?.addEventListener('abort', abort, { once: true })
|
|
@@ -134,26 +77,22 @@ export function createAssetClient(transport: AssetTransport) {
|
|
|
134
77
|
try {
|
|
135
78
|
if (signal?.aborted) controller.abort()
|
|
136
79
|
if (controller.signal.aborted) throw abortError()
|
|
137
|
-
const reference = await transport.upload(file, controller.signal)
|
|
80
|
+
const reference = await transport.upload(file, controller.signal, options)
|
|
138
81
|
if (controller.signal.aborted) throw abortError()
|
|
139
|
-
return { asset_id: reference.asset_id
|
|
82
|
+
return { asset_id: reference.asset_id }
|
|
140
83
|
} finally {
|
|
141
84
|
controllers.delete(controller)
|
|
142
85
|
signal?.removeEventListener('abort', abort)
|
|
143
86
|
}
|
|
144
87
|
},
|
|
145
|
-
/** Call synchronously before logout/tenant/account transitions. */
|
|
146
88
|
clearSession() {
|
|
147
|
-
inFlight.clear()
|
|
148
89
|
for (const controller of controllers) controller.abort()
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
revision.value++
|
|
90
|
+
controllers.clear()
|
|
91
|
+
revision++
|
|
152
92
|
},
|
|
153
93
|
}
|
|
154
94
|
}
|
|
155
95
|
|
|
156
|
-
export type AssetClient = ReturnType<typeof createAssetClient>
|
|
157
96
|
export const ASSET_CLIENT_KEY: InjectionKey<AssetClient> = Symbol('bagel:assets')
|
|
158
97
|
|
|
159
98
|
/** Opt-in upload state; original bytes are handed unchanged to the transport. */
|
|
@@ -169,14 +108,14 @@ export function useAssetUpload() {
|
|
|
169
108
|
uploading.value = false
|
|
170
109
|
reference.value = undefined
|
|
171
110
|
}
|
|
172
|
-
async function upload(file: File): Promise<AssetReference> {
|
|
111
|
+
async function upload(file: File, options?: AssetUploadOptions): Promise<AssetReference> {
|
|
173
112
|
cancel()
|
|
174
113
|
if (!client) throw new Error('Asset client is not configured')
|
|
175
114
|
const current = generation
|
|
176
115
|
controller = new AbortController()
|
|
177
116
|
uploading.value = true
|
|
178
117
|
try {
|
|
179
|
-
const result = await client.upload(file, controller.signal)
|
|
118
|
+
const result = await client.upload(file, controller.signal, options)
|
|
180
119
|
if (current !== generation) throw abortError()
|
|
181
120
|
reference.value = result
|
|
182
121
|
return result
|
|
@@ -184,62 +123,40 @@ export function useAssetUpload() {
|
|
|
184
123
|
if (current === generation) uploading.value = false
|
|
185
124
|
}
|
|
186
125
|
}
|
|
187
|
-
const stop = client ? watch(client.revision, cancel, { flush: 'sync' }) : undefined
|
|
126
|
+
const stop = client ? watch(() => client.revision, cancel, { flush: 'sync' }) : undefined
|
|
188
127
|
onScopeDispose(() => { cancel(); stop?.() })
|
|
189
128
|
return { upload, cancel, uploading, reference }
|
|
190
129
|
}
|
|
191
130
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Resolve an asset reference to a public display URL. Public-by-default means
|
|
133
|
+
* no server round-trip and no expiry: the URL is deterministic from the asset id.
|
|
134
|
+
*/
|
|
135
|
+
export function useAssetUrl(source: MaybeRefOrGetter<AssetDeliveryRequest | undefined>) {
|
|
194
136
|
const client = inject(ASSET_CLIENT_KEY, undefined)
|
|
195
137
|
const url = ref<string>()
|
|
196
138
|
const error = ref(false)
|
|
197
|
-
let controller: AbortController | undefined
|
|
198
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
199
|
-
let generation = 0
|
|
200
|
-
let retried = false
|
|
201
|
-
let invalidated = false
|
|
202
139
|
|
|
203
|
-
|
|
204
|
-
generation++
|
|
205
|
-
controller?.abort()
|
|
206
|
-
clearTimeout(timer)
|
|
207
|
-
if (!keepUrl) url.value = undefined
|
|
208
|
-
}
|
|
209
|
-
async function load(refresh = false) {
|
|
210
|
-
cancel(refresh)
|
|
211
|
-
error.value = false
|
|
212
|
-
if (invalidated) return
|
|
140
|
+
watch(() => {
|
|
213
141
|
const reference = toValue(source)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
url.value = delivery.url
|
|
222
|
-
const remaining = Date.parse(delivery.expires_at) - Date.now()
|
|
223
|
-
timer = setTimeout(() => { void load(true) }, Math.min(2_147_483_647, Math.max(1, remaining * 0.8)))
|
|
224
|
-
} catch {
|
|
225
|
-
if (current === generation) { error.value = true; url.value = undefined }
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
function onError() {
|
|
229
|
-
if (retried) { cancel(); error.value = true; return }
|
|
230
|
-
retried = true
|
|
231
|
-
void load()
|
|
232
|
-
}
|
|
233
|
-
watch(() => [toValue(source)?.asset_id, toValue(source)?.version_id, toValue(source)?.representation], () => {
|
|
234
|
-
retried = false
|
|
235
|
-
invalidated = false
|
|
236
|
-
void load()
|
|
142
|
+
return reference ? `${reference.asset_id}:${reference.representation ?? 'original'}` : ''
|
|
143
|
+
}, () => {
|
|
144
|
+
const reference = toValue(source)
|
|
145
|
+
if (!reference) { url.value = undefined; error.value = false; return }
|
|
146
|
+
if (!client) { url.value = undefined; error.value = true; return }
|
|
147
|
+
url.value = client.assetUrl(reference, reference.representation)
|
|
148
|
+
error.value = false
|
|
237
149
|
}, { immediate: true })
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
150
|
+
|
|
151
|
+
return { url, error }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @deprecated Backward-compatible alias for ``useAssetUrl``. Public assets have
|
|
156
|
+
* no expiry, so the returned URL never needs refreshing; ``onError`` is a no-op
|
|
157
|
+
* retry hook kept for callers that expected the old grant-refresh contract.
|
|
158
|
+
*/
|
|
159
|
+
export function useAssetDelivery(source: MaybeRefOrGetter<AssetDeliveryRequest | undefined>) {
|
|
160
|
+
const { url, error } = useAssetUrl(source)
|
|
161
|
+
return { url, error, onError: () => { error.value = true } }
|
|
245
162
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VirtualElement } from '@floating-ui/dom'
|
|
3
|
+
import type { AlignedPlacement, AnchorPoint, DropdownItem } from './Dropdown.types'
|
|
4
|
+
import { computed, ref, useSlots } from 'vue'
|
|
5
|
+
import Dropdown from './Dropdown.vue'
|
|
6
|
+
|
|
7
|
+
defineOptions({ inheritAttrs: false, name: 'BglContextMenu' })
|
|
8
|
+
|
|
9
|
+
const props = withDefaults(defineProps<{
|
|
10
|
+
/** Declarative menu items. */
|
|
11
|
+
items?: DropdownItem[]
|
|
12
|
+
/** Optional header title shown at the top of the context menu. */
|
|
13
|
+
title?: string
|
|
14
|
+
/** Explicit viewport anchor `{ x, y, width?, height? }` for controlled mode. */
|
|
15
|
+
anchorPoint?: AnchorPoint | null
|
|
16
|
+
/** External reference element or VirtualElement. */
|
|
17
|
+
referenceEl?: Element | VirtualElement | null
|
|
18
|
+
placement?: AlignedPlacement
|
|
19
|
+
popupClass?: string
|
|
20
|
+
autoHide?: boolean
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
}>(), {
|
|
23
|
+
placement: 'bottom-start',
|
|
24
|
+
autoHide: true,
|
|
25
|
+
disabled: false,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{
|
|
29
|
+
show: []
|
|
30
|
+
hide: []
|
|
31
|
+
select: [value: string | number | DropdownItem, item: DropdownItem]
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const shown = defineModel('shown', { type: Boolean, default: false })
|
|
35
|
+
const slots = useSlots()
|
|
36
|
+
const dropdownRef = ref<InstanceType<typeof Dropdown> | null>(null)
|
|
37
|
+
const internalPoint = ref<AnchorPoint>({ x: 0, y: 0 })
|
|
38
|
+
|
|
39
|
+
const hasTargetSlot = computed(() => Boolean(slots.target))
|
|
40
|
+
const effectiveAnchorPoint = computed<AnchorPoint | null>(() => {
|
|
41
|
+
if (props.anchorPoint) return props.anchorPoint
|
|
42
|
+
if (props.referenceEl) return null
|
|
43
|
+
return internalPoint.value
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const effectiveItems = computed<DropdownItem[]>(() => {
|
|
47
|
+
if (!props.items?.length) return []
|
|
48
|
+
if (props.title) {
|
|
49
|
+
return [{ header: props.title }, ...props.items]
|
|
50
|
+
}
|
|
51
|
+
return props.items
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
function openAt(target: AnchorPoint | MouseEvent) {
|
|
55
|
+
if (props.disabled) return
|
|
56
|
+
if ('clientX' in target && 'clientY' in target) {
|
|
57
|
+
internalPoint.value = { x: target.clientX, y: target.clientY, width: 0, height: 0 }
|
|
58
|
+
} else {
|
|
59
|
+
internalPoint.value = { ...target }
|
|
60
|
+
}
|
|
61
|
+
dropdownRef.value?.openAt(internalPoint.value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function onTargetContextMenu(e: MouseEvent) {
|
|
65
|
+
if (props.disabled) return
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
openAt(e)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function show() {
|
|
71
|
+
dropdownRef.value?.show()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hide() {
|
|
75
|
+
dropdownRef.value?.hide()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
defineExpose({ openAt, show, hide, shown })
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<div
|
|
83
|
+
v-if="hasTargetSlot"
|
|
84
|
+
v-bind="$attrs"
|
|
85
|
+
style="display: contents"
|
|
86
|
+
@contextmenu="onTargetContextMenu"
|
|
87
|
+
>
|
|
88
|
+
<slot name="target" :open-at="openAt" :hide="hide" :shown="shown" />
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<Dropdown
|
|
92
|
+
ref="dropdownRef"
|
|
93
|
+
v-model:shown="shown"
|
|
94
|
+
:anchor-point="effectiveAnchorPoint"
|
|
95
|
+
:reference-el="referenceEl"
|
|
96
|
+
:placement="placement"
|
|
97
|
+
:items="effectiveItems"
|
|
98
|
+
:popup-class="popupClass"
|
|
99
|
+
:auto-hide="autoHide"
|
|
100
|
+
:disabled="disabled"
|
|
101
|
+
:triggers="[]"
|
|
102
|
+
@show="emit('show')"
|
|
103
|
+
@hide="emit('hide')"
|
|
104
|
+
@select="(val, item) => emit('select', val, item)"
|
|
105
|
+
>
|
|
106
|
+
<template #default="slotProps">
|
|
107
|
+
<div v-if="title && !effectiveItems.length" class="px-05 py-025 txt11 semi opacity-5 uppercase">
|
|
108
|
+
{{ title }}
|
|
109
|
+
</div>
|
|
110
|
+
<slot v-bind="slotProps" />
|
|
111
|
+
</template>
|
|
112
|
+
</Dropdown>
|
|
113
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IconType, ThemeType } from '../types'
|
|
2
|
+
|
|
3
|
+
export type Side = 'top' | 'right' | 'bottom' | 'left'
|
|
4
|
+
export type Alignment = 'start' | 'end'
|
|
5
|
+
export type AlignedPlacement = 'auto' | Side | `${Side}-${Alignment}`
|
|
6
|
+
export type TriggerEvent = 'click' | 'hover'
|
|
7
|
+
|
|
8
|
+
export interface AnchorPoint {
|
|
9
|
+
x: number
|
|
10
|
+
y: number
|
|
11
|
+
width?: number
|
|
12
|
+
height?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DropdownItem {
|
|
16
|
+
label?: string
|
|
17
|
+
value?: string | number
|
|
18
|
+
icon?: IconType
|
|
19
|
+
color?: ThemeType
|
|
20
|
+
danger?: boolean
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
active?: boolean
|
|
23
|
+
divider?: boolean
|
|
24
|
+
header?: string
|
|
25
|
+
class?: string
|
|
26
|
+
action?: () => void
|
|
27
|
+
}
|