@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiscozen/composables",
3
- "version": "0.1.39-next.0",
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": {
@@ -1,5 +1,32 @@
1
1
  <script lang="ts" setup>
2
- import { ref, useSlots, watch, toRef, computed, onBeforeUnmount, toRefs, defineExpose } from 'vue'
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
- props.isOpen && floating.setPosition()
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
- window.removeEventListener('scroll', setPositionWhenOpen)
160
+ if (!newVal || !content.value || !isMounted) {
161
+ cleanupEventListeners()
75
162
  return
76
163
  }
77
- window.addEventListener('scroll', setPositionWhenOpen)
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
- window.removeEventListener('scroll', setPositionWhenOpen)
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 p-4', props.contentClass]
217
+ return ['bg-core-white fixed', resolvedMarginClass.value, props.contentClass]
111
218
  })
112
219
 
113
220
  defineExpose({