@fiscozen/composables 0.1.39-next.0 → 1.0.1

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.
@@ -1,391 +1,582 @@
1
- import { FzFloatingPosition, FzRect, FzUseFloatingArgs } from '../types'
1
+ /**
2
+ * @module useFloating
3
+ * @description Composable for managing floating/popover element positioning
4
+ *
5
+ * ## Why This Refactoring?
6
+ *
7
+ * The previous implementation had several issues:
8
+ *
9
+ * 1. **Monolithic function**: All positioning logic was in a single function
10
+ * making it hard to test individual positioning strategies
11
+ *
12
+ * 2. **Inconsistent margin handling**: Margins were calculated differently for
13
+ * different positions, leading to visual inconsistencies
14
+ *
15
+ * 3. **No reactive repositioning**: The floating element didn't respond to
16
+ * opener/content size changes or scroll events
17
+ *
18
+ * 4. **Layout shift on open**: The floating element was added to document flow
19
+ * before positioning, causing visual jumps
20
+ *
21
+ * ## Architecture
22
+ *
23
+ * The new implementation uses:
24
+ *
25
+ * - **Pure position calculators**: Each position (top, bottom, left, right and
26
+ * their variants) has a dedicated calculator function stored in lookup tables
27
+ *
28
+ * - **Lookup tables**: `positionCalculators` (with opener) and
29
+ * `containerPositionCalculators` (without opener) provide O(1) access
30
+ *
31
+ * - **Immediate fixed positioning**: Elements are set to `position: fixed`
32
+ * immediately to prevent layout shift
33
+ *
34
+ * - **Reactive repositioning**: ResizeObserver and scroll/resize event listeners
35
+ * ensure the floating stays positioned correctly during dynamic changes
36
+ *
37
+ * ## Usage
38
+ *
39
+ * ```typescript
40
+ * const { float, setPosition, actualPosition } = useFloating({
41
+ * element: toRef(props, 'element'),
42
+ * opener: toRef(props, 'opener'),
43
+ * position: toRef(props, 'position'),
44
+ * container: toRef(props, 'container')
45
+ * })
46
+ *
47
+ * // Position is set automatically, or call manually:
48
+ * await setPosition()
49
+ *
50
+ * // Access computed position values:
51
+ * console.log(float.position.x, float.position.y)
52
+ * console.log(actualPosition.value) // Resolved position for 'auto' modes
53
+ * ```
54
+ *
55
+ * ## Position Types
56
+ *
57
+ * - Explicit: top, bottom, left, right and variants (-start, -end)
58
+ * - Auto: auto, auto-vertical, auto-start, auto-end, etc.
59
+ *
60
+ * Auto positions are resolved based on available space around the opener
61
+ *
62
+ * @see FzFloating.vue - The component that uses this composable
63
+ */
64
+
65
+ import { FzFloatingPosition, FzRect, FzUseFloatingArgs, FzAbsolutePosition } from '../types'
2
66
  import { ref, reactive, onUnmounted, Ref, nextTick, ToRefs } from 'vue'
3
67
  import { calcRealPos, getHighestAvailableSpacePos } from '../utils'
4
68
 
5
- export const useFloating = (
6
- args: ToRefs<FzUseFloatingArgs>
7
- ): {
8
- float: FzRect
9
- rect: Ref<DOMRect | undefined>
10
- setPosition: (ignoreCallback?: boolean) => Promise<void>
11
- position: Ref<FzFloatingPosition>
12
- actualPosition?: Ref<FzFloatingPosition | undefined>
13
- openerRect: Ref<DOMRect | undefined>
14
- containerRect: Ref<DOMRect | undefined>
15
- } => {
16
- const safeElementDomRef = ref<HTMLElement | null>(null)
17
- const safeContainerDomRef = ref<HTMLElement | null>(null)
18
- const safeOpenerDomRef = ref<HTMLElement | null>(null)
19
- const openerRect = ref<DOMRect | undefined>()
20
- const containerRect = ref<DOMRect | undefined>()
21
- const position = ref<FzFloatingPosition>('auto')
22
- const rect = ref<DOMRect | undefined>()
23
- const float = reactive<FzRect>({
24
- position: { x: 0, y: 0 }
25
- })
26
- const options: IntersectionObserverInit = {
27
- root: null,
28
- rootMargin: '0px',
29
- threshold: 1.0,
30
- ...args.element.value.intersectionOptions
31
- }
69
+ // ============================================================================
70
+ // Types
71
+ // ============================================================================
32
72
 
33
- const handleIntersect = (
34
- entries: IntersectionObserverEntry[],
35
- observer: IntersectionObserver
36
- ) => {}
73
+ interface DOMRefs {
74
+ element: HTMLElement
75
+ container: HTMLElement
76
+ opener: HTMLElement | null
77
+ }
37
78
 
38
- const floatObserver = ref(new IntersectionObserver(handleIntersect, options))
39
- const actualPosition = ref<FzFloatingPosition>()
79
+ interface Rects {
80
+ element: DOMRect
81
+ container: DOMRect
82
+ opener: DOMRect | null
83
+ }
40
84
 
41
- // Helper: Resolve DOM references
42
- const resolveDomReferences = () => {
43
- safeElementDomRef.value = (
44
- typeof args.element.value.domRef.value === 'string'
45
- ? document.querySelector(args.element.value.domRef.value)
46
- : args.element.value.domRef.value
47
- ) as HTMLElement
48
-
49
- if (!args.container?.value) {
50
- safeContainerDomRef.value = document.body
51
- } else {
52
- safeContainerDomRef.value = (
53
- typeof args.container.value?.domRef.value === 'string'
54
- ? document.querySelector(args.container.value.domRef.value)
55
- : args.container.value?.domRef.value
56
- ) as HTMLElement
57
- safeContainerDomRef.value ??= document.body
58
- }
85
+ interface Margins {
86
+ left: number
87
+ right: number
88
+ top: number
89
+ bottom: number
90
+ }
59
91
 
60
- if (!safeElementDomRef.value) {
61
- throw new Error('missing reference element for floating behavior')
62
- }
92
+ interface Transform {
93
+ x: number
94
+ y: number
95
+ }
63
96
 
64
- if (args.opener?.value) {
65
- safeOpenerDomRef.value = (
66
- typeof args.opener.value?.domRef.value === 'string'
67
- ? document.querySelector(args.opener.value.domRef.value)
68
- : args.opener.value?.domRef.value
69
- ) as HTMLElement
70
- }
97
+ interface PositionResult {
98
+ position: FzAbsolutePosition
99
+ transform: Transform
100
+ }
71
101
 
72
- rect.value = safeElementDomRef.value.getBoundingClientRect()
73
- openerRect.value = safeOpenerDomRef.value?.getBoundingClientRect()
74
- containerRect.value = safeContainerDomRef.value.getBoundingClientRect()
75
- }
102
+ type PositionCalculator = (opener: DOMRect, margins: Margins) => PositionResult
103
+
104
+ // ============================================================================
105
+ // Pure Functions - DOM Reference Resolution
106
+ // ============================================================================
107
+
108
+ const resolveElement = (domRef: string | HTMLElement | null): HTMLElement | null =>
109
+ typeof domRef === 'string' ? document.querySelector(domRef) : domRef
110
+
111
+ const getMargins = (style: CSSStyleDeclaration): Margins => ({
112
+ left: parseFloat(style.marginLeft),
113
+ right: parseFloat(style.marginRight),
114
+ top: parseFloat(style.marginTop),
115
+ bottom: parseFloat(style.marginBottom)
116
+ })
117
+
118
+ // ============================================================================
119
+ // Position Calculators WITH Opener - Lookup Table
120
+ // Each calculator receives opener rect and element margins for consistent spacing
121
+ // ============================================================================
122
+
123
+ const positionCalculators: Record<string, PositionCalculator> = {
124
+ // Bottom positions - content below opener
125
+ 'bottom': (opener, margins) => ({
126
+ position: {
127
+ x: opener.left - margins.left + opener.width / 2,
128
+ y: opener.bottom
129
+ },
130
+ transform: { x: -50, y: 0 }
131
+ }),
132
+
133
+ 'bottom-start': (opener, margins) => ({
134
+ position: {
135
+ x: opener.left - margins.left,
136
+ y: opener.bottom
137
+ },
138
+ transform: { x: 0, y: 0 }
139
+ }),
140
+
141
+ 'bottom-end': (opener, margins) => ({
142
+ position: {
143
+ x: opener.right + margins.right,
144
+ y: opener.bottom
145
+ },
146
+ transform: { x: -100, y: 0 }
147
+ }),
148
+
149
+ // Top positions - content above opener
150
+ 'top': (opener, margins) => ({
151
+ position: {
152
+ x: opener.left - margins.left + opener.width / 2,
153
+ y: opener.top - margins.bottom
154
+ },
155
+ transform: { x: -50, y: -100 }
156
+ }),
157
+
158
+ 'top-start': (opener, margins) => ({
159
+ position: {
160
+ x: opener.left - margins.left,
161
+ y: opener.top - margins.bottom
162
+ },
163
+ transform: { x: 0, y: -100 }
164
+ }),
165
+
166
+ 'top-end': (opener, margins) => ({
167
+ position: {
168
+ x: opener.right + margins.right,
169
+ y: opener.top - margins.bottom
170
+ },
171
+ transform: { x: -100, y: -100 }
172
+ }),
173
+
174
+ // Left positions - content to left of opener
175
+ 'left': (opener, margins) => ({
176
+ position: {
177
+ x: opener.left - margins.right,
178
+ y: opener.top - margins.top + opener.height / 2
179
+ },
180
+ transform: { x: -100, y: -50 }
181
+ }),
182
+
183
+ 'left-start': (opener, margins) => ({
184
+ position: {
185
+ x: opener.left - margins.right,
186
+ y: opener.top - margins.top
187
+ },
188
+ transform: { x: -100, y: 0 }
189
+ }),
190
+
191
+ 'left-end': (opener, margins) => ({
192
+ position: {
193
+ x: opener.left - margins.right,
194
+ y: opener.bottom + margins.bottom
195
+ },
196
+ transform: { x: -100, y: -100 }
197
+ }),
198
+
199
+ // Right positions - content to right of opener
200
+ 'right': (opener, margins) => ({
201
+ position: {
202
+ x: opener.right + margins.left,
203
+ y: opener.top - margins.top + opener.height / 2
204
+ },
205
+ transform: { x: 0, y: -50 }
206
+ }),
207
+
208
+ 'right-start': (opener, margins) => ({
209
+ position: {
210
+ x: opener.right + margins.left,
211
+ y: opener.top - margins.top
212
+ },
213
+ transform: { x: 0, y: 0 }
214
+ }),
215
+
216
+ 'right-end': (opener, margins) => ({
217
+ position: {
218
+ x: opener.right + margins.left,
219
+ y: opener.bottom + margins.bottom
220
+ },
221
+ transform: { x: 0, y: -100 }
222
+ })
223
+ }
76
224
 
77
- // Helper: Resolve auto position to actual position
78
- const resolveAutoPositionValue = () => {
79
- if (!args.opener?.value || !safeOpenerDomRef.value) return
225
+ const calculatePositionWithOpener = (
226
+ position: FzFloatingPosition,
227
+ opener: DOMRect,
228
+ margins: Margins
229
+ ): PositionResult => {
230
+ const calculator = positionCalculators[position]
231
+ return calculator
232
+ ? calculator(opener, margins)
233
+ : { position: { x: 0, y: 0 }, transform: { x: 0, y: 0 } }
234
+ }
80
235
 
81
- switch (actualPosition.value) {
82
- case 'auto':
83
- actualPosition.value = getHighestAvailableSpacePos(
84
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
85
- safeElementDomRef.value as HTMLElement,
86
- safeOpenerDomRef.value as HTMLElement
87
- )
88
- break
89
- case 'auto-vertical':
90
- actualPosition.value = getHighestAvailableSpacePos(
91
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
92
- safeElementDomRef.value as HTMLElement,
93
- safeOpenerDomRef.value as HTMLElement,
94
- undefined,
95
- true
96
- )
97
- break
98
- case 'auto-start':
99
- actualPosition.value = getHighestAvailableSpacePos(
100
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
101
- safeElementDomRef.value as HTMLElement,
102
- safeOpenerDomRef.value as HTMLElement,
103
- 'start'
104
- )
105
- break
106
- case 'auto-vertical-start':
107
- actualPosition.value = getHighestAvailableSpacePos(
108
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
109
- safeElementDomRef.value as HTMLElement,
110
- safeOpenerDomRef.value as HTMLElement,
111
- 'start',
112
- true
113
- )
114
- break
115
- case 'auto-end':
116
- actualPosition.value = getHighestAvailableSpacePos(
117
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
118
- safeElementDomRef.value as HTMLElement,
119
- safeOpenerDomRef.value as HTMLElement,
120
- 'end'
121
- )
122
- break
123
- case 'auto-vertical-end':
124
- actualPosition.value = getHighestAvailableSpacePos(
125
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
126
- safeElementDomRef.value as HTMLElement,
127
- safeOpenerDomRef.value as HTMLElement,
128
- 'end',
129
- true
130
- )
131
- break
132
- default:
133
- break
134
- }
236
+ // ============================================================================
237
+ // Position Calculators WITHOUT Opener - Lookup Table
238
+ // ============================================================================
239
+
240
+ type ContainerPositionCalculator = (container: DOMRect, element: DOMRect) => PositionResult
241
+
242
+ const containerPositionCalculators: Record<string, ContainerPositionCalculator> = {
243
+ 'bottom': (container, element) => ({
244
+ position: {
245
+ x: container.left + container.width / 2,
246
+ y: container.bottom - element.height
247
+ },
248
+ transform: { x: -50, y: 0 }
249
+ }),
250
+
251
+ 'bottom-start': (container, element) => ({
252
+ position: {
253
+ x: container.left,
254
+ y: container.bottom - element.height
255
+ },
256
+ transform: { x: 0, y: 0 }
257
+ }),
258
+
259
+ 'bottom-end': (container, element) => ({
260
+ position: {
261
+ x: container.right - element.width,
262
+ y: container.bottom - element.height
263
+ },
264
+ transform: { x: 0, y: 0 }
265
+ }),
266
+
267
+ 'top': (container) => ({
268
+ position: {
269
+ x: container.left + container.width / 2,
270
+ y: container.top
271
+ },
272
+ transform: { x: -50, y: 0 }
273
+ }),
274
+
275
+ 'top-start': (container) => ({
276
+ position: {
277
+ x: container.left,
278
+ y: container.top
279
+ },
280
+ transform: { x: 0, y: 0 }
281
+ }),
282
+
283
+ 'top-end': (container, element) => ({
284
+ position: {
285
+ x: container.right - element.width,
286
+ y: container.top
287
+ },
288
+ transform: { x: 0, y: 0 }
289
+ }),
290
+
291
+ 'left': (container, element) => ({
292
+ position: {
293
+ x: container.left,
294
+ y: container.top + (container.height - element.height) / 2
295
+ },
296
+ transform: { x: 0, y: 0 }
297
+ }),
298
+
299
+ 'left-start': (container) => ({
300
+ position: {
301
+ x: container.left,
302
+ y: container.top
303
+ },
304
+ transform: { x: 0, y: 0 }
305
+ }),
306
+
307
+ 'left-end': (container, element) => ({
308
+ position: {
309
+ x: container.left,
310
+ y: container.bottom - element.height
311
+ },
312
+ transform: { x: 0, y: 0 }
313
+ }),
314
+
315
+ 'right': (container, element) => ({
316
+ position: {
317
+ x: container.right - element.width,
318
+ y: container.top + (container.height - element.height) / 2
319
+ },
320
+ transform: { x: 0, y: 0 }
321
+ }),
322
+
323
+ 'right-start': (container, element) => ({
324
+ position: {
325
+ x: container.right - element.width,
326
+ y: container.top
327
+ },
328
+ transform: { x: 0, y: 0 }
329
+ }),
330
+
331
+ 'right-end': (container, element) => ({
332
+ position: {
333
+ x: container.right - element.width,
334
+ y: container.bottom - element.height
335
+ },
336
+ transform: { x: 0, y: 0 }
337
+ })
338
+ }
339
+
340
+ const calculatePositionWithoutOpener = (
341
+ position: FzFloatingPosition,
342
+ container: DOMRect,
343
+ element: DOMRect
344
+ ): PositionResult => {
345
+ const calculator = containerPositionCalculators[position]
346
+ return calculator
347
+ ? calculator(container, element)
348
+ : { position: { x: 0, y: 0 }, transform: { x: 0, y: 0 } }
349
+ }
350
+
351
+ // ============================================================================
352
+ // Boundary Correction - Pure Function
353
+ // ============================================================================
354
+
355
+ const applyBoundaryCorrections = (
356
+ realPosition: FzAbsolutePosition,
357
+ element: DOMRect,
358
+ container: DOMRect,
359
+ transform: Transform
360
+ ): { position: FzAbsolutePosition; transform: Transform } => {
361
+ const correctedPosition = { ...realPosition }
362
+ const correctedTransform = { ...transform }
363
+
364
+ // Left boundary
365
+ if (realPosition.x < container.left) {
366
+ correctedPosition.x = container.left
367
+ correctedTransform.x = 0
135
368
  }
136
369
 
137
- // Helper: Calculate position with opener
138
- const calculatePositionWithOpener = (
139
- elStyle: CSSStyleDeclaration,
140
- translateX: { value: number },
141
- translateY: { value: number }
142
- ) => {
143
- if (!openerRect.value) return
144
-
145
- const leftWithoutXMargin =
146
- openerRect.value.left - parseFloat(elStyle.marginLeft) - parseFloat(elStyle.marginRight)
147
- const leftWithoutLeftMargin = openerRect.value.left - parseFloat(elStyle.marginLeft)
148
- const topWithoutYMargin =
149
- openerRect.value.top - parseFloat(elStyle.marginTop) - parseFloat(elStyle.marginBottom)
150
- const topWithoutTopMargin = openerRect.value.top - parseFloat(elStyle.marginTop)
151
-
152
- switch (actualPosition.value) {
153
- case 'bottom':
154
- float.position.y = openerRect.value.bottom
155
- float.position.x = leftWithoutLeftMargin + openerRect.value.width / 2
156
- translateX.value = -50
157
- translateY.value = 0
158
- break
159
- case 'bottom-start':
160
- float.position.y = openerRect.value.bottom
161
- float.position.x = leftWithoutXMargin
162
- translateX.value = 0
163
- translateY.value = 0
164
- break
165
- case 'bottom-end':
166
- float.position.y = openerRect.value.bottom
167
- float.position.x = openerRect.value.right
168
- translateX.value = -100
169
- translateY.value = 0
170
- break
171
- case 'left-start':
172
- float.position.y = topWithoutYMargin
173
- float.position.x = leftWithoutXMargin
174
- translateX.value = -100
175
- translateY.value = 0
176
- break
177
- case 'left':
178
- float.position.y = topWithoutTopMargin + openerRect.value.height / 2
179
- float.position.x = leftWithoutXMargin
180
- translateY.value = -50
181
- translateX.value = -100
182
- break
183
- case 'left-end':
184
- float.position.y = openerRect.value.bottom
185
- float.position.x = leftWithoutXMargin
186
- translateY.value = -100
187
- translateX.value = -100
188
- break
189
- case 'top-start':
190
- float.position.y = topWithoutYMargin
191
- float.position.x = leftWithoutXMargin
192
- translateY.value = -100
193
- translateX.value = 0
194
- break
195
- case 'top':
196
- float.position.y = topWithoutYMargin
197
- float.position.x = leftWithoutLeftMargin + openerRect.value.width / 2
198
- translateX.value = -50
199
- translateY.value = -100
200
- break
201
- case 'top-end':
202
- float.position.y = topWithoutYMargin
203
- float.position.x = openerRect.value.right
204
- translateX.value = -100
205
- translateY.value = -100
206
- break
207
- case 'right-start':
208
- float.position.y = topWithoutYMargin
209
- float.position.x = openerRect.value.right
210
- translateX.value = 0
211
- translateY.value = 0
212
- break
213
- case 'right':
214
- float.position.y = topWithoutTopMargin + openerRect.value.height / 2
215
- float.position.x = openerRect.value.right
216
- translateY.value = -50
217
- translateX.value = 0
218
- break
219
- case 'right-end':
220
- float.position.y = openerRect.value.bottom
221
- float.position.x = openerRect.value.right
222
- translateY.value = -100
223
- translateX.value = 0
224
- break
225
- default:
226
- break
370
+ // Right boundary
371
+ if (realPosition.x + element.width > container.right) {
372
+ const fixedX = container.right - element.width
373
+ if (fixedX > 0) {
374
+ correctedPosition.x = fixedX
227
375
  }
376
+ correctedTransform.x = 0
228
377
  }
229
378
 
230
- // Helper: Calculate position without opener
231
- const calculatePositionWithoutOpener = (translateX: { value: number }) => {
232
- if (!containerRect.value || !rect.value) return
233
-
234
- switch (actualPosition.value) {
235
- case 'bottom':
236
- float.position.y = containerRect.value.bottom - rect.value.height
237
- float.position.x = containerRect.value.left + containerRect.value.width / 2
238
- translateX.value = -50
239
- break
240
- case 'left-end':
241
- case 'bottom-start':
242
- float.position.y = containerRect.value.bottom - rect.value.height
243
- float.position.x = containerRect.value.left
244
- break
245
- case 'right-end':
246
- case 'bottom-end':
247
- float.position.y = containerRect.value.bottom - rect.value.height
248
- float.position.x = containerRect.value.right - rect.value.width
249
- break
250
- case 'left':
251
- float.position.y =
252
- containerRect.value.top + (containerRect.value.height - rect.value.height) / 2
253
- float.position.x = containerRect.value.left
254
- break
255
- case 'top-start':
256
- case 'left-start':
257
- float.position.y = containerRect.value.top
258
- float.position.x = containerRect.value.left
259
- break
260
- case 'top':
261
- float.position.y = containerRect.value.top
262
- float.position.x =
263
- containerRect.value.left + (containerRect.value.width - rect.value.width) / 2
264
- break
265
- case 'top-end':
266
- case 'right-start':
267
- float.position.y = containerRect.value.top
268
- float.position.x = containerRect.value.right - rect.value.width
269
- break
270
- case 'right':
271
- float.position.y =
272
- containerRect.value.top + (containerRect.value.height - rect.value.height) / 2
273
- float.position.x = containerRect.value.right - rect.value.width
274
- break
275
- default:
276
- break
277
- }
379
+ // Top boundary
380
+ if (realPosition.y < container.top) {
381
+ correctedPosition.y = container.top
382
+ correctedTransform.y = 0
278
383
  }
279
384
 
280
- // Helper: Apply boundary corrections
281
- const applyBoundaryCorrections = (
282
- realPos: { x: number; y: number },
283
- translateX: { value: number },
284
- translateY: { value: number }
285
- ) => {
286
- if (!containerRect.value || !rect.value) return
287
-
288
- if (realPos.x < containerRect.value.left) {
289
- float.position.x = containerRect.value.left
290
- translateX.value = 0
385
+ // Bottom boundary
386
+ if (realPosition.y + element.height > container.bottom) {
387
+ const fixedY = container.bottom - element.height
388
+ if (fixedY > 0) {
389
+ correctedPosition.y = fixedY
291
390
  }
391
+ correctedTransform.y = 0
392
+ }
292
393
 
293
- if (realPos.x + rect.value.width > containerRect.value.right) {
294
- const fixVal = containerRect.value.right - rect.value.width
295
- if (fixVal > 0) {
296
- float.position.x = fixVal
297
- }
298
- translateX.value = 0
299
- }
394
+ return { position: correctedPosition, transform: correctedTransform }
395
+ }
300
396
 
301
- if (realPos.y < containerRect.value.top) {
302
- float.position.y = containerRect.value.top
303
- translateY.value = 0
304
- }
397
+ // ============================================================================
398
+ // Auto Position Resolution - Pure Function
399
+ // ============================================================================
305
400
 
306
- if (realPos.y + rect.value.height > containerRect.value.bottom) {
307
- const fixVal = containerRect.value.bottom - rect.value.height
308
- if (fixVal > 0) {
309
- float.position.y = fixVal
310
- }
311
- translateY.value = 0
312
- }
313
- }
401
+ type AutoPositionType = 'auto' | 'auto-vertical' | 'auto-start' | 'auto-vertical-start' | 'auto-end' | 'auto-vertical-end'
314
402
 
315
- // Helper: Apply element styles
316
- const applyElementStyles = () => {
317
- if (!safeElementDomRef.value) return
403
+ const resolveAutoPosition = (
404
+ autoType: AutoPositionType,
405
+ container: HTMLElement | null,
406
+ element: HTMLElement,
407
+ opener: HTMLElement,
408
+ useViewport: boolean
409
+ ): FzFloatingPosition => {
410
+ const containerEl = useViewport ? null : container
318
411
 
319
- safeElementDomRef.value.style.top = `${float.position.y}px`
320
- safeElementDomRef.value.style.left = `${float.position.x}px`
321
- safeElementDomRef.value.style.position = 'fixed'
322
- safeElementDomRef.value.style.display = 'flex'
323
- }
412
+ switch (autoType) {
413
+ case 'auto':
414
+ return getHighestAvailableSpacePos(containerEl, element, opener)
324
415
 
325
- const setPosition = (ignoreCallback: boolean = false) =>
326
- nextTick(() => {
327
- actualPosition.value = args.position ? args.position.value : 'auto'
416
+ case 'auto-vertical':
417
+ return getHighestAvailableSpacePos(containerEl, element, opener, undefined, true)
328
418
 
329
- // Step 1: Resolve DOM references
330
- resolveDomReferences()
419
+ case 'auto-start':
420
+ return getHighestAvailableSpacePos(containerEl, element, opener, 'start')
331
421
 
332
- // Step 1.5: CRITICAL - Remove element from document flow immediately
333
- // This prevents the floating element from pushing the opener and causing layout shift
334
- if (safeElementDomRef.value) {
335
- safeElementDomRef.value.style.position = 'fixed'
336
- safeElementDomRef.value.style.top = '0px'
337
- safeElementDomRef.value.style.left = '0px'
338
- }
422
+ case 'auto-vertical-start':
423
+ return getHighestAvailableSpacePos(containerEl, element, opener, 'start', true)
339
424
 
340
- // Recalculate all rects after content is rendered
341
- rect.value = safeElementDomRef.value!.getBoundingClientRect()
342
- openerRect.value = safeOpenerDomRef.value?.getBoundingClientRect()
343
- containerRect.value = safeContainerDomRef.value!.getBoundingClientRect()
425
+ case 'auto-end':
426
+ return getHighestAvailableSpacePos(containerEl, element, opener, 'end')
344
427
 
345
- const elStyle = window.getComputedStyle(safeElementDomRef.value as HTMLElement)
428
+ case 'auto-vertical-end':
429
+ return getHighestAvailableSpacePos(containerEl, element, opener, 'end', true)
346
430
 
347
- // Step 2: Setup intersection observer
348
- floatObserver.value.observe(safeElementDomRef.value as HTMLElement)
349
- floatObserver.value.observe(safeContainerDomRef.value as HTMLElement)
431
+ default:
432
+ return 'bottom-start'
433
+ }
434
+ }
350
435
 
351
- // Step 3: Resolve auto positions
352
- resolveAutoPositionValue()
436
+ const isAutoPosition = (position: FzFloatingPosition): position is AutoPositionType =>
437
+ position.startsWith('auto')
353
438
 
354
- // Step 4: Calculate position based on opener presence
355
- const translate = { x: { value: 0 }, y: { value: 0 } }
439
+ // ============================================================================
440
+ // Main Composable
441
+ // ============================================================================
356
442
 
357
- if (args.opener?.value && safeOpenerDomRef.value && openerRect?.value) {
358
- calculatePositionWithOpener(elStyle, translate.x, translate.y)
359
- } else {
360
- calculatePositionWithoutOpener(translate.x)
361
- }
443
+ export const useFloating = (
444
+ args: ToRefs<FzUseFloatingArgs>
445
+ ): {
446
+ float: FzRect
447
+ rect: Ref<DOMRect | undefined>
448
+ setPosition: (ignoreCallback?: boolean) => Promise<void>
449
+ position: Ref<FzFloatingPosition>
450
+ actualPosition?: Ref<FzFloatingPosition | undefined>
451
+ openerRect: Ref<DOMRect | undefined>
452
+ containerRect: Ref<DOMRect | undefined>
453
+ } => {
454
+ // State
455
+ const position = ref<FzFloatingPosition>('auto')
456
+ const actualPosition = ref<FzFloatingPosition>()
457
+ const rect = ref<DOMRect>()
458
+ const openerRect = ref<DOMRect>()
459
+ const containerRect = ref<DOMRect>()
460
+ const float = reactive<FzRect>({ position: { x: 0, y: 0 } })
362
461
 
363
- // Step 5: Calculate real position and apply boundary corrections
364
- if (rect.value) {
365
- const realPos = calcRealPos(
366
- rect.value,
367
- float.position,
368
- translate.x.value,
369
- translate.y.value
370
- )
371
- float.position.x = realPos.x
372
- float.position.y = realPos.y
462
+ // Intersection Observer
463
+ const observerOptions: IntersectionObserverInit = {
464
+ root: null,
465
+ rootMargin: '0px',
466
+ threshold: 1.0,
467
+ ...args.element.value.intersectionOptions
468
+ }
469
+ const floatObserver = ref(new IntersectionObserver(() => {}, observerOptions))
470
+
471
+ // DOM Reference Resolution
472
+ const resolveDOMRefs = (): DOMRefs | null => {
473
+ const element = resolveElement(args.element.value.domRef.value)
474
+ if (!element) {
475
+ // Return null instead of throwing - the element might be unmounted
476
+ // or not yet rendered. Callers should handle null gracefully.
477
+ return null
478
+ }
373
479
 
374
- applyBoundaryCorrections(realPos, translate.x, translate.y)
375
- }
480
+ const container = args.container?.value
481
+ ? resolveElement(args.container.value.domRef.value) ?? document.body
482
+ : document.body
376
483
 
377
- // Step 6: Apply styles to element
378
- applyElementStyles()
484
+ const opener = args.opener?.value
485
+ ? resolveElement(args.opener.value.domRef.value)
486
+ : null
379
487
 
380
- // Step 7: Update rect after positioning
381
- rect.value = safeElementDomRef.value!.getBoundingClientRect()
488
+ return { element, container, opener }
489
+ }
490
+
491
+ const getRects = (refs: DOMRefs): Rects => ({
492
+ element: refs.element.getBoundingClientRect(),
493
+ container: refs.container.getBoundingClientRect(),
494
+ opener: refs.opener?.getBoundingClientRect() ?? null
495
+ })
496
+
497
+ // Main positioning function
498
+ const setPosition = (ignoreCallback: boolean = false) =>
499
+ nextTick(() => {
500
+ // Step 1: Initialize position from args
501
+ actualPosition.value = args.position?.value ?? 'auto'
502
+
503
+ // Step 2: Resolve DOM references
504
+ const refs = resolveDOMRefs()
505
+ if (!refs) return
506
+
507
+ // Step 3: Set fixed positioning immediately to prevent layout shift
508
+ Object.assign(refs.element.style, {
509
+ position: 'fixed',
510
+ top: '0px',
511
+ left: '0px'
512
+ })
513
+
514
+ // Step 4: Get all bounding rects
515
+ const rects = getRects(refs)
516
+ rect.value = rects.element
517
+ openerRect.value = rects.opener ?? undefined
518
+ containerRect.value = rects.container
519
+
520
+ // Step 5: Setup observers
521
+ floatObserver.value.observe(refs.element)
522
+ floatObserver.value.observe(refs.container)
523
+
524
+ // Step 6: Resolve auto position if needed
525
+ if (isAutoPosition(actualPosition.value) && refs.opener) {
526
+ actualPosition.value = resolveAutoPosition(
527
+ actualPosition.value,
528
+ refs.container,
529
+ refs.element,
530
+ refs.opener,
531
+ args.useViewport?.value ?? false
532
+ )
533
+ }
382
534
 
383
- // Step 8: Trigger callback if provided
535
+ // Step 7: Calculate position
536
+ const margins = getMargins(window.getComputedStyle(refs.element))
537
+
538
+ const positionResult = refs.opener && rects.opener
539
+ ? calculatePositionWithOpener(actualPosition.value!, rects.opener, margins)
540
+ : calculatePositionWithoutOpener(actualPosition.value!, rects.container, rects.element)
541
+
542
+ // Step 8: Apply transform to get real position
543
+ const realPosition = calcRealPos(
544
+ rects.element,
545
+ positionResult.position,
546
+ positionResult.transform.x,
547
+ positionResult.transform.y
548
+ )
549
+
550
+ // Step 9: Apply boundary corrections
551
+ const corrected = applyBoundaryCorrections(
552
+ realPosition,
553
+ rects.element,
554
+ rects.container,
555
+ positionResult.transform
556
+ )
557
+
558
+ // Step 10: Update float state
559
+ float.position.x = corrected.position.x
560
+ float.position.y = corrected.position.y
561
+
562
+ // Step 11: Apply final styles
563
+ Object.assign(refs.element.style, {
564
+ top: `${float.position.y}px`,
565
+ left: `${float.position.x}px`,
566
+ position: 'fixed',
567
+ display: 'flex'
568
+ })
569
+
570
+ // Step 12: Update rect after final positioning
571
+ rect.value = refs.element.getBoundingClientRect()
572
+
573
+ // Step 13: Trigger callback if provided
384
574
  if (args.callback?.value && !ignoreCallback) {
385
575
  args.callback.value(rect, openerRect, containerRect, position, actualPosition)
386
576
  }
387
577
  })
388
578
 
579
+ // Cleanup
389
580
  onUnmounted(() => {
390
581
  floatObserver.value.disconnect()
391
582
  })