@fiscozen/composables 0.1.39-next.0 → 1.0.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 +3 -3
- package/src/FzFloating.vue +115 -8
- package/src/composables/useFloating.ts +534 -345
- package/dist/composables.js +0 -1451
- package/dist/composables.umd.cjs +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiscozen/composables",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Design System utility composables",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"vite": "^5.0.10",
|
|
29
29
|
"vitest": "^1.2.0",
|
|
30
30
|
"vue-tsc": "^1.8.25",
|
|
31
|
-
"@fiscozen/eslint-config": "^0.1.0",
|
|
32
31
|
"@fiscozen/prettier-config": "^0.1.0",
|
|
33
|
-
"@fiscozen/tsconfig": "^0.1.0"
|
|
32
|
+
"@fiscozen/tsconfig": "^0.1.0",
|
|
33
|
+
"@fiscozen/eslint-config": "^0.1.0"
|
|
34
34
|
},
|
|
35
35
|
"license": "ISC",
|
|
36
36
|
"scripts": {
|
package/src/FzFloating.vue
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* FzFloating Component
|
|
4
|
+
*
|
|
5
|
+
* A floating container that positions itself relative to an opener element.
|
|
6
|
+
* Supports automatic repositioning on scroll, resize, and content changes.
|
|
7
|
+
*
|
|
8
|
+
* ## Features
|
|
9
|
+
* - 12 positioning options (top/bottom/left/right with start/center/end alignment)
|
|
10
|
+
* - Auto-positioning based on available viewport space
|
|
11
|
+
* - Reactive repositioning via ResizeObserver
|
|
12
|
+
* - Teleport support for z-index stacking context issues
|
|
13
|
+
* - Automatic margin based on resolved position direction
|
|
14
|
+
*
|
|
15
|
+
* ## Slots
|
|
16
|
+
* - `opener`: The element that triggers/anchors the floating content
|
|
17
|
+
* - `default`: The floating content to display
|
|
18
|
+
*
|
|
19
|
+
* ## Example
|
|
20
|
+
* ```vue
|
|
21
|
+
* <FzFloating position="bottom-start" :isOpen="showMenu">
|
|
22
|
+
* <template #opener>
|
|
23
|
+
* <button @click="showMenu = !showMenu">Menu</button>
|
|
24
|
+
* </template>
|
|
25
|
+
* <div>Menu content here</div>
|
|
26
|
+
* </FzFloating>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
import { ref, useSlots, watch, toRef, computed, onBeforeUnmount, onMounted, toRefs } from 'vue'
|
|
3
30
|
import { useFloating } from './composables'
|
|
4
31
|
import { FzFloatingProps, FzUseFloatingArgs } from './types'
|
|
5
32
|
import { useMediaQuery } from './composables'
|
|
@@ -21,6 +48,11 @@ const slots = useSlots()
|
|
|
21
48
|
const xs = useMediaQuery(`(max-width: ${breakpoints.xs})`)
|
|
22
49
|
|
|
23
50
|
let scheduledAnimationFrame = false
|
|
51
|
+
let isMounted = false
|
|
52
|
+
|
|
53
|
+
// Observers for reactive repositioning
|
|
54
|
+
let resizeObserver: ResizeObserver | null = null
|
|
55
|
+
let openerResizeObserver: ResizeObserver | null = null
|
|
24
56
|
|
|
25
57
|
const useFloatingOpts: FzUseFloatingArgs = {
|
|
26
58
|
position: props.position,
|
|
@@ -52,17 +84,72 @@ if (slots.opener) {
|
|
|
52
84
|
const floating = useFloating(dynamicOpts)
|
|
53
85
|
|
|
54
86
|
const setPositionWhenOpen = () => {
|
|
55
|
-
if (scheduledAnimationFrame) {
|
|
87
|
+
if (scheduledAnimationFrame || !isMounted) {
|
|
56
88
|
return
|
|
57
89
|
}
|
|
58
90
|
|
|
59
91
|
scheduledAnimationFrame = true
|
|
60
92
|
requestAnimationFrame(() => {
|
|
61
|
-
|
|
93
|
+
if (isMounted && props.isOpen) {
|
|
94
|
+
floating.setPosition()
|
|
95
|
+
}
|
|
62
96
|
scheduledAnimationFrame = false
|
|
63
97
|
})
|
|
64
98
|
}
|
|
65
99
|
|
|
100
|
+
onMounted(() => {
|
|
101
|
+
isMounted = true
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Scroll handler that catches scroll events on any scrollable parent
|
|
105
|
+
const handleScroll = () => {
|
|
106
|
+
setPositionWhenOpen()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Window resize handler
|
|
110
|
+
const handleResize = () => {
|
|
111
|
+
setPositionWhenOpen()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Setup all event listeners and observers
|
|
115
|
+
const setupEventListeners = () => {
|
|
116
|
+
// Window scroll and resize
|
|
117
|
+
window.addEventListener('scroll', handleScroll, true) // capture phase for nested scrollables
|
|
118
|
+
window.addEventListener('resize', handleResize)
|
|
119
|
+
|
|
120
|
+
// ResizeObserver for floating content (handles content size changes)
|
|
121
|
+
if (content.value && !resizeObserver) {
|
|
122
|
+
resizeObserver = new ResizeObserver(() => {
|
|
123
|
+
setPositionWhenOpen()
|
|
124
|
+
})
|
|
125
|
+
resizeObserver.observe(content.value)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ResizeObserver for opener (handles opener size/position changes)
|
|
129
|
+
if (opener.value && !openerResizeObserver) {
|
|
130
|
+
openerResizeObserver = new ResizeObserver(() => {
|
|
131
|
+
setPositionWhenOpen()
|
|
132
|
+
})
|
|
133
|
+
openerResizeObserver.observe(opener.value)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Cleanup all event listeners and observers
|
|
138
|
+
const cleanupEventListeners = () => {
|
|
139
|
+
window.removeEventListener('scroll', handleScroll, true)
|
|
140
|
+
window.removeEventListener('resize', handleResize)
|
|
141
|
+
|
|
142
|
+
if (resizeObserver) {
|
|
143
|
+
resizeObserver.disconnect()
|
|
144
|
+
resizeObserver = null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (openerResizeObserver) {
|
|
148
|
+
openerResizeObserver.disconnect()
|
|
149
|
+
openerResizeObserver = null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
66
153
|
watch(
|
|
67
154
|
() => props.position,
|
|
68
155
|
() => setPositionWhenOpen()
|
|
@@ -70,11 +157,13 @@ watch(
|
|
|
70
157
|
watch(
|
|
71
158
|
() => props.isOpen,
|
|
72
159
|
(newVal) => {
|
|
73
|
-
if (!newVal || !content.value) {
|
|
74
|
-
|
|
160
|
+
if (!newVal || !content.value || !isMounted) {
|
|
161
|
+
cleanupEventListeners()
|
|
75
162
|
return
|
|
76
163
|
}
|
|
77
|
-
|
|
164
|
+
|
|
165
|
+
setupEventListeners()
|
|
166
|
+
|
|
78
167
|
const openerRect = opener.value?.getBoundingClientRect()
|
|
79
168
|
// CRITICAL: Set position fixed immediately to prevent layout shift
|
|
80
169
|
content.value.style.position = 'fixed'
|
|
@@ -98,8 +187,26 @@ watch(
|
|
|
98
187
|
}
|
|
99
188
|
}
|
|
100
189
|
)
|
|
190
|
+
|
|
101
191
|
onBeforeUnmount(() => {
|
|
102
|
-
|
|
192
|
+
isMounted = false
|
|
193
|
+
cleanupEventListeners()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// Helper function to get margin class from position
|
|
197
|
+
const getMarginClassForPosition = (position: string): string => {
|
|
198
|
+
if (position.startsWith('bottom')) return 'mt-4' // margin-top only
|
|
199
|
+
if (position.startsWith('top')) return 'mb-4' // margin-bottom only
|
|
200
|
+
if (position.startsWith('left')) return 'mr-4' // margin-right only
|
|
201
|
+
if (position.startsWith('right')) return 'ml-4' // margin-left only
|
|
202
|
+
return 'mt-4' // default fallback
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Computed margin class based on resolved position
|
|
206
|
+
// Uses actualPosition for auto positions, falls back to props.position for explicit
|
|
207
|
+
const resolvedMarginClass = computed(() => {
|
|
208
|
+
const effectivePosition = floating.actualPosition?.value ?? props.position
|
|
209
|
+
return getMarginClassForPosition(effectivePosition)
|
|
103
210
|
})
|
|
104
211
|
|
|
105
212
|
const contentClass = computed(() => {
|
|
@@ -107,7 +214,7 @@ const contentClass = computed(() => {
|
|
|
107
214
|
return props.contentClass
|
|
108
215
|
}
|
|
109
216
|
|
|
110
|
-
return ['bg-core-white fixed
|
|
217
|
+
return ['bg-core-white fixed', resolvedMarginClass.value, props.contentClass]
|
|
111
218
|
})
|
|
112
219
|
|
|
113
220
|
defineExpose({
|