@wishbone-media/spark 0.36.0 → 0.38.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishbone-media/spark",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -65,6 +65,8 @@
65
65
  "access": "public"
66
66
  },
67
67
  "dependencies": {
68
+ "@fancyapps/ui": "^6.1.13",
69
+ "@floating-ui/vue": "^1.1.11",
68
70
  "@googlemaps/js-api-loader": "^2.0.2"
69
71
  }
70
72
  }
@@ -0,0 +1,7 @@
1
+ /*
2
+ * SparkFancybox styles — re-exports Fancybox CSS.
3
+ *
4
+ * Import in consuming app:
5
+ * @import '@wishbone-media/spark/assets/css/spark-fancybox.css';
6
+ */
7
+ @import '@fancyapps/ui/dist/fancybox/fancybox.css';
@@ -0,0 +1,44 @@
1
+ /*
2
+ * SparkTooltip & v-spark-tooltip styles
3
+ *
4
+ * Import in consuming app:
5
+ * @import '@wishbone-media/spark/assets/css/spark-tooltip.css';
6
+ */
7
+
8
+ .spark-tooltip {
9
+ z-index: 9999;
10
+ max-width: 300px;
11
+ padding: 0.375rem 0.625rem;
12
+ font-size: 0.75rem;
13
+ line-height: 1.25rem;
14
+ color: #fff;
15
+ background: #1f2937; /* gray-800 */
16
+ border-radius: 0.375rem;
17
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
18
+ pointer-events: none;
19
+ word-wrap: break-word;
20
+ white-space: pre-line;
21
+ }
22
+
23
+ .spark-tooltip-arrow {
24
+ position: absolute;
25
+ width: 8px;
26
+ height: 8px;
27
+ background: #1f2937; /* same as tooltip bg */
28
+ transform: rotate(45deg);
29
+ }
30
+
31
+ /* Transitions (for SparkTooltip component) */
32
+ .spark-tooltip-enter-active {
33
+ transition: opacity 150ms ease, transform 150ms ease;
34
+ }
35
+
36
+ .spark-tooltip-leave-active {
37
+ transition: opacity 100ms ease, transform 100ms ease;
38
+ }
39
+
40
+ .spark-tooltip-enter-from,
41
+ .spark-tooltip-leave-to {
42
+ opacity: 0;
43
+ transform: scale(0.95);
44
+ }
@@ -0,0 +1,175 @@
1
+ <template>
2
+ <component
3
+ :is="isLink ? 'a' : 'span'"
4
+ :href="resolvedHref || undefined"
5
+ :target="isLink && openNew ? '_blank' : undefined"
6
+ :rel="isLink && openNew ? 'noopener noreferrer' : undefined"
7
+ v-spark-tooltip="tooltip"
8
+ class="inline-flex items-center gap-1 rounded-full font-medium whitespace-nowrap"
9
+ :class="[variantClasses, sizeClasses, interactiveClasses]"
10
+ @click="handleClick"
11
+ >
12
+ <font-awesome-icon v-if="icon" :icon="icon" />
13
+ <slot>{{ label }}</slot>
14
+ <font-awesome-icon
15
+ v-if="copyToClipboard"
16
+ :icon="Icons.farCopy"
17
+ class="opacity-60"
18
+ />
19
+ <font-awesome-icon
20
+ v-else-if="isNavigable && openNew"
21
+ :icon="Icons.farArrowUpRightFromSquare"
22
+ class="opacity-60 text-[0.6em]"
23
+ />
24
+ </component>
25
+ </template>
26
+
27
+ <script setup>
28
+ import { computed } from 'vue'
29
+ import { useRouter } from 'vue-router'
30
+ import { Icons } from '../plugins/fontawesome'
31
+ import { sparkNotificationService } from '../composables/sparkNotificationService'
32
+
33
+ const props = defineProps({
34
+ /** Display text */
35
+ label: {
36
+ type: String,
37
+ default: '',
38
+ },
39
+ /** FontAwesome icon object */
40
+ icon: {
41
+ type: [Object, Array],
42
+ default: null,
43
+ },
44
+ /** Color scheme */
45
+ variant: {
46
+ type: String,
47
+ default: 'primary',
48
+ },
49
+ /** Size: 'sm', 'md', 'lg' */
50
+ size: {
51
+ type: String,
52
+ default: 'md',
53
+ validator: (v) => ['sm', 'md', 'lg'].includes(v),
54
+ },
55
+ /** Tooltip text (native title attribute) */
56
+ tooltip: {
57
+ type: String,
58
+ default: '',
59
+ },
60
+ /** Vue Router route name for navigation */
61
+ routeName: {
62
+ type: String,
63
+ default: null,
64
+ },
65
+ /** Entity ID — used with routeName as { name, params: { id } } */
66
+ entityId: {
67
+ type: [Number, String],
68
+ default: null,
69
+ },
70
+ /** External link URL */
71
+ href: {
72
+ type: String,
73
+ default: null,
74
+ },
75
+ /** Open link in new tab */
76
+ openNew: {
77
+ type: Boolean,
78
+ default: false,
79
+ },
80
+ /** Copy tooltip content to clipboard on click */
81
+ copyToClipboard: {
82
+ type: Boolean,
83
+ default: false,
84
+ },
85
+ /** Disable all interaction */
86
+ disabled: {
87
+ type: Boolean,
88
+ default: false,
89
+ },
90
+ })
91
+
92
+ const emit = defineEmits(['click'])
93
+
94
+ const router = useRouter()
95
+
96
+ // --- Variant classes ---
97
+
98
+ const VARIANT_CLASSES = {
99
+ primary: 'bg-blue-100 text-blue-700',
100
+ secondary: 'bg-gray-100 text-gray-600',
101
+ success: 'bg-green-100 text-green-700',
102
+ warning: 'bg-yellow-100 text-yellow-700',
103
+ danger: 'bg-red-100 text-red-700',
104
+ info: 'bg-cyan-100 text-cyan-700',
105
+ muted: 'bg-gray-50 text-gray-500',
106
+ bordered: 'border border-gray-300 text-gray-700 bg-white',
107
+ 'bordered-blue': 'border border-blue-300 text-blue-600 bg-white',
108
+ }
109
+
110
+ const SIZE_CLASSES = {
111
+ sm: 'text-[11px] px-1.5 py-0.5',
112
+ md: 'text-xs px-2 py-0.5',
113
+ lg: 'text-sm px-2.5 py-1',
114
+ }
115
+
116
+ const variantClasses = computed(() => VARIANT_CLASSES[props.variant] || VARIANT_CLASSES.primary)
117
+ const sizeClasses = computed(() => SIZE_CLASSES[props.size] || SIZE_CLASSES.md)
118
+
119
+ // --- Navigation ---
120
+
121
+ const isNavigable = computed(
122
+ () => (props.routeName && props.entityId != null) || props.href,
123
+ )
124
+ const isInteractive = computed(() => isNavigable.value || props.copyToClipboard)
125
+ const isLink = computed(() => isNavigable.value && !props.disabled)
126
+
127
+ const resolvedHref = computed(() => {
128
+ if (props.href) return props.href
129
+ if (props.routeName && props.entityId != null) {
130
+ try {
131
+ return router.resolve({ name: props.routeName, params: { id: props.entityId } }).href
132
+ } catch {
133
+ return null
134
+ }
135
+ }
136
+ return null
137
+ })
138
+
139
+ const interactiveClasses = computed(() => {
140
+ if (props.disabled) return 'opacity-50 cursor-not-allowed'
141
+ if (isInteractive.value) return 'cursor-pointer hover:opacity-80 transition-opacity'
142
+ return 'cursor-default'
143
+ })
144
+
145
+ // --- Click handling ---
146
+
147
+ function handleClick(event) {
148
+ if (props.disabled) return
149
+
150
+ emit('click')
151
+
152
+ if (props.copyToClipboard && props.tooltip) {
153
+ event.preventDefault()
154
+ navigator.clipboard.writeText(props.tooltip)
155
+ sparkNotificationService.toast({ type: 'info', message: 'Copied to clipboard' })
156
+ return
157
+ }
158
+
159
+ // href links: native <a> handles openNew via target="_blank"
160
+ // For same-tab href, let native <a> handle it
161
+ if (props.href) return
162
+
163
+ // Route-based navigation
164
+ if (props.routeName && props.entityId != null) {
165
+ event.preventDefault()
166
+ const route = { name: props.routeName, params: { id: props.entityId } }
167
+ if (props.openNew) {
168
+ const resolved = router.resolve(route)
169
+ window.open(resolved.href, '_blank')
170
+ } else {
171
+ router.push(route)
172
+ }
173
+ }
174
+ }
175
+ </script>
@@ -0,0 +1,59 @@
1
+ <template>
2
+ <div ref="containerRef">
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup>
8
+ /**
9
+ * Vue 3 wrapper around Fancybox.
10
+ * Wraps a container element and binds Fancybox to all [data-fancybox] children.
11
+ * Handles lifecycle: bind on mount, rebind on update (for dynamic content), unbind on unmount.
12
+ *
13
+ * Usage:
14
+ * <SparkFancybox>
15
+ * <a href="full.jpg" data-fancybox="gallery" data-caption="Photo #1">
16
+ * <img src="thumb.jpg" />
17
+ * </a>
18
+ * </SparkFancybox>
19
+ */
20
+ import { ref, onMounted, onUpdated, onUnmounted } from 'vue'
21
+ import { Fancybox } from '@fancyapps/ui/dist/fancybox/'
22
+
23
+ const props = defineProps({
24
+ /** Fancybox configuration options */
25
+ options: {
26
+ type: Object,
27
+ default: () => ({}),
28
+ },
29
+ /** CSS selector for bindable elements within the container */
30
+ selector: {
31
+ type: String,
32
+ default: '[data-fancybox]',
33
+ },
34
+ })
35
+
36
+ const containerRef = ref(null)
37
+
38
+ function bind() {
39
+ if (!containerRef.value) return
40
+ Fancybox.bind(containerRef.value, props.selector, { ...props.options })
41
+ }
42
+
43
+ function unbind() {
44
+ if (!containerRef.value) return
45
+ Fancybox.unbind(containerRef.value)
46
+ }
47
+
48
+ onMounted(() => bind())
49
+
50
+ onUpdated(() => {
51
+ unbind()
52
+ bind()
53
+ })
54
+
55
+ onUnmounted(() => {
56
+ unbind()
57
+ Fancybox.close()
58
+ })
59
+ </script>
@@ -1,8 +1,8 @@
1
1
  <template>
2
2
  <div>
3
3
  <label v-if="label" class="block text-sm font-medium text-gray-700 mb-2">{{ label }}</label>
4
- <div v-if="modelValue" class="mb-2 relative inline-block">
5
- <a :href="modelValue" target="_blank" rel="noopener noreferrer">
4
+ <SparkFancybox v-if="modelValue" class="mb-2 relative inline-block">
5
+ <a :href="modelValue" data-fancybox="image-upload" :data-caption="label || ''">
6
6
  <img
7
7
  :src="modelValue"
8
8
  :alt="label || 'Image preview'"
@@ -17,7 +17,7 @@
17
17
  >
18
18
  <font-awesome-icon :icon="Icons.farXmark" class="text-xs" />
19
19
  </button>
20
- </div>
20
+ </SparkFancybox>
21
21
  <div
22
22
  v-if="!modelValue"
23
23
  class="relative rounded-md transition-colors"
@@ -51,6 +51,7 @@
51
51
  import { ref, inject } from 'vue'
52
52
  import { Icons } from '@/plugins/fontawesome'
53
53
  import { sparkNotificationService } from '@/composables/sparkNotificationService'
54
+ import SparkFancybox from './SparkFancybox.vue'
54
55
 
55
56
  const props = defineProps({
56
57
  modelValue: {
@@ -0,0 +1,162 @@
1
+ <template>
2
+ <div
3
+ ref="referenceRef"
4
+ class="inline-flex"
5
+ @mouseenter="show"
6
+ @mouseleave="hide"
7
+ @focusin="show"
8
+ @focusout="hide"
9
+ >
10
+ <slot />
11
+ </div>
12
+
13
+ <Teleport to="body">
14
+ <Transition name="spark-tooltip">
15
+ <div
16
+ v-if="isVisible"
17
+ ref="floatingRef"
18
+ :style="floatingStyles"
19
+ class="spark-tooltip"
20
+ role="tooltip"
21
+ >
22
+ <slot name="content">
23
+ <span v-if="html" v-html="content" />
24
+ <span v-else>{{ content }}</span>
25
+ </slot>
26
+ <div
27
+ v-if="showArrow"
28
+ ref="arrowRef"
29
+ class="spark-tooltip-arrow"
30
+ :style="arrowStyles"
31
+ />
32
+ </div>
33
+ </Transition>
34
+ </Teleport>
35
+ </template>
36
+
37
+ <script setup>
38
+ /**
39
+ * Tooltip component using @floating-ui/vue for positioning.
40
+ * Wraps a trigger element (default slot) and displays a floating tooltip on hover/focus.
41
+ *
42
+ * For simple text tooltips, prefer the v-spark-tooltip directive.
43
+ * Use this component when you need slot-based HTML content or complex tooltip layouts.
44
+ */
45
+ import { ref, computed, useSlots, onUnmounted } from 'vue'
46
+ import {
47
+ useFloating,
48
+ autoUpdate,
49
+ offset as offsetMiddleware,
50
+ flip,
51
+ shift,
52
+ arrow as arrowMiddleware,
53
+ } from '@floating-ui/vue'
54
+
55
+ const props = defineProps({
56
+ /** Tooltip text (alternative to #content slot) */
57
+ content: {
58
+ type: String,
59
+ default: '',
60
+ },
61
+ /** Render content prop as HTML */
62
+ html: {
63
+ type: Boolean,
64
+ default: false,
65
+ },
66
+ /** Tooltip placement */
67
+ placement: {
68
+ type: String,
69
+ default: 'top',
70
+ },
71
+ /** Distance in px from trigger */
72
+ offset: {
73
+ type: Number,
74
+ default: 8,
75
+ },
76
+ /** Show/hide delay in ms. Number applies to show only; object for { show, hide } */
77
+ delay: {
78
+ type: [Number, Object],
79
+ default: () => ({ show: 200, hide: 0 }),
80
+ },
81
+ /** Disable tooltip */
82
+ disabled: {
83
+ type: Boolean,
84
+ default: false,
85
+ },
86
+ /** Show arrow pointing to trigger */
87
+ showArrow: {
88
+ type: Boolean,
89
+ default: true,
90
+ },
91
+ })
92
+
93
+ const slots = useSlots()
94
+
95
+ const referenceRef = ref(null)
96
+ const floatingRef = ref(null)
97
+ const arrowRef = ref(null)
98
+ const isVisible = ref(false)
99
+
100
+ let showTimeout = null
101
+ let hideTimeout = null
102
+
103
+ const middleware = computed(() => {
104
+ const mw = [
105
+ offsetMiddleware(props.offset),
106
+ flip(),
107
+ shift({ padding: 8 }),
108
+ ]
109
+ if (props.showArrow) {
110
+ mw.push(arrowMiddleware({ element: arrowRef, padding: 5 }))
111
+ }
112
+ return mw
113
+ })
114
+
115
+ const { floatingStyles, middlewareData, placement: actualPlacement } = useFloating(
116
+ referenceRef,
117
+ floatingRef,
118
+ {
119
+ placement: computed(() => props.placement),
120
+ middleware,
121
+ whileElementsMounted: autoUpdate,
122
+ },
123
+ )
124
+
125
+ const arrowStyles = computed(() => {
126
+ const data = middlewareData.value?.arrow
127
+ if (!data) return {}
128
+
129
+ const side = actualPlacement.value.split('-')[0]
130
+ const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[side]
131
+
132
+ return {
133
+ left: data.x != null ? `${data.x}px` : '',
134
+ top: data.y != null ? `${data.y}px` : '',
135
+ [staticSide]: '-4px',
136
+ }
137
+ })
138
+
139
+ function show() {
140
+ if (props.disabled) return
141
+ if (!props.content && !slots.content) return
142
+
143
+ clearTimeout(hideTimeout)
144
+ const delay = typeof props.delay === 'number' ? props.delay : props.delay.show ?? 200
145
+ showTimeout = setTimeout(() => {
146
+ isVisible.value = true
147
+ }, delay)
148
+ }
149
+
150
+ function hide() {
151
+ clearTimeout(showTimeout)
152
+ const delay = typeof props.delay === 'number' ? 0 : props.delay.hide ?? 0
153
+ hideTimeout = setTimeout(() => {
154
+ isVisible.value = false
155
+ }, delay)
156
+ }
157
+
158
+ onUnmounted(() => {
159
+ clearTimeout(showTimeout)
160
+ clearTimeout(hideTimeout)
161
+ })
162
+ </script>
@@ -7,6 +7,9 @@ export { default as SparkBrandSelector } from './SparkBrandSelector.vue'
7
7
  export { default as SparkButton } from './SparkButton.vue'
8
8
  export { default as SparkButtonGroup } from './SparkButtonGroup.vue'
9
9
  export { default as SparkCard } from './SparkCard.vue'
10
+ export { default as SparkEntityBadge } from './SparkEntityBadge.vue'
11
+ export { default as SparkFancybox } from './SparkFancybox.vue'
12
+ export { default as SparkTooltip } from './SparkTooltip.vue'
10
13
  export { default as SparkFileDragUpload } from './SparkFileDragUpload.vue'
11
14
  export { default as SparkImageUpload } from './SparkImageUpload.vue'
12
15
  export { default as SparkModalContainer } from './SparkModalContainer.vue'