@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.
- package/dist/composables.js +463 -461
- package/dist/composables.umd.cjs +1 -1
- package/package.json +4 -4
- package/src/FzFloating.vue +115 -8
- package/src/__tests__/__snapshots__/FzFloating.spec.ts.snap +12 -12
- package/src/__tests__/useFloating.spec.ts +14 -6
- package/src/composables/useFloating.ts +536 -345
- package/vitest.config.ts +9 -1
|
@@ -1,391 +1,582 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
73
|
+
interface DOMRefs {
|
|
74
|
+
element: HTMLElement
|
|
75
|
+
container: HTMLElement
|
|
76
|
+
opener: HTMLElement | null
|
|
77
|
+
}
|
|
37
78
|
|
|
38
|
-
|
|
39
|
-
|
|
79
|
+
interface Rects {
|
|
80
|
+
element: DOMRect
|
|
81
|
+
container: DOMRect
|
|
82
|
+
opener: DOMRect | null
|
|
83
|
+
}
|
|
40
84
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
interface Transform {
|
|
93
|
+
x: number
|
|
94
|
+
y: number
|
|
95
|
+
}
|
|
63
96
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
: args.opener.value?.domRef.value
|
|
69
|
-
) as HTMLElement
|
|
70
|
-
}
|
|
97
|
+
interface PositionResult {
|
|
98
|
+
position: FzAbsolutePosition
|
|
99
|
+
transform: Transform
|
|
100
|
+
}
|
|
71
101
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
397
|
+
// ============================================================================
|
|
398
|
+
// Auto Position Resolution - Pure Function
|
|
399
|
+
// ============================================================================
|
|
305
400
|
|
|
306
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
safeElementDomRef.value.style.display = 'flex'
|
|
323
|
-
}
|
|
412
|
+
switch (autoType) {
|
|
413
|
+
case 'auto':
|
|
414
|
+
return getHighestAvailableSpacePos(containerEl, element, opener)
|
|
324
415
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
actualPosition.value = args.position ? args.position.value : 'auto'
|
|
416
|
+
case 'auto-vertical':
|
|
417
|
+
return getHighestAvailableSpacePos(containerEl, element, opener, undefined, true)
|
|
328
418
|
|
|
329
|
-
|
|
330
|
-
|
|
419
|
+
case 'auto-start':
|
|
420
|
+
return getHighestAvailableSpacePos(containerEl, element, opener, 'start')
|
|
331
421
|
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
428
|
+
case 'auto-vertical-end':
|
|
429
|
+
return getHighestAvailableSpacePos(containerEl, element, opener, 'end', true)
|
|
346
430
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
431
|
+
default:
|
|
432
|
+
return 'bottom-start'
|
|
433
|
+
}
|
|
434
|
+
}
|
|
350
435
|
|
|
351
|
-
|
|
352
|
-
|
|
436
|
+
const isAutoPosition = (position: FzFloatingPosition): position is AutoPositionType =>
|
|
437
|
+
position.startsWith('auto')
|
|
353
438
|
|
|
354
|
-
|
|
355
|
-
|
|
439
|
+
// ============================================================================
|
|
440
|
+
// Main Composable
|
|
441
|
+
// ============================================================================
|
|
356
442
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
480
|
+
const container = args.container?.value
|
|
481
|
+
? resolveElement(args.container.value.domRef.value) ?? document.body
|
|
482
|
+
: document.body
|
|
376
483
|
|
|
377
|
-
|
|
378
|
-
|
|
484
|
+
const opener = args.opener?.value
|
|
485
|
+
? resolveElement(args.opener.value.domRef.value)
|
|
486
|
+
: null
|
|
379
487
|
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
})
|