@fiscozen/composables 0.1.36 → 0.1.38-beta.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,11 +1,14 @@
1
1
  {
2
2
  "name": "@fiscozen/composables",
3
- "version": "0.1.36",
3
+ "version": "0.1.38-beta.0",
4
4
  "description": "Design System utility composables",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
7
7
  "keywords": [],
8
8
  "author": "Riccardo Agnoletto",
9
+ "dependencies": {
10
+ "@fiscozen/style": "^0.1.8-next.0"
11
+ },
9
12
  "peerDependencies": {
10
13
  "tailwindcss": "^3.4.1",
11
14
  "vue": "^3.4.13"
@@ -25,9 +28,9 @@
25
28
  "vite": "^5.0.10",
26
29
  "vitest": "^1.2.0",
27
30
  "vue-tsc": "^1.8.25",
31
+ "@fiscozen/prettier-config": "^0.1.0",
28
32
  "@fiscozen/eslint-config": "^0.1.0",
29
- "@fiscozen/tsconfig": "^0.1.0",
30
- "@fiscozen/prettier-config": "^0.1.0"
33
+ "@fiscozen/tsconfig": "^0.1.0"
31
34
  },
32
35
  "license": "ISC",
33
36
  "scripts": {
@@ -2,6 +2,8 @@
2
2
  import { ref, useSlots, watch, toRef, computed, onBeforeUnmount, toRefs, defineExpose } from 'vue'
3
3
  import { useFloating } from './composables'
4
4
  import { FzFloatingProps, FzUseFloatingArgs } from './types'
5
+ import { useMediaQuery } from './composables'
6
+ import { breakpoints } from '@fiscozen/style'
5
7
 
6
8
  const props = withDefaults(defineProps<FzFloatingProps>(), {
7
9
  position: 'auto',
@@ -16,6 +18,8 @@ const opener = ref<HTMLElement | null>(null)
16
18
 
17
19
  const slots = useSlots()
18
20
 
21
+ const xs = useMediaQuery(`(max-width: ${breakpoints.xs})`)
22
+
19
23
  let scheduledAnimationFrame = false
20
24
 
21
25
  const useFloatingOpts: FzUseFloatingArgs = {
@@ -71,9 +75,13 @@ watch(
71
75
  return
72
76
  }
73
77
  window.addEventListener('scroll', setPositionWhenOpen)
78
+ const openerRect = opener.value?.getBoundingClientRect()
79
+ // CRITICAL: Set position fixed immediately to prevent layout shift
80
+ content.value.style.position = 'fixed'
74
81
  content.value.style.top = '0px'
75
82
  content.value.style.left = '0px'
76
83
  content.value.style.transform = 'none'
84
+ content.value.style.width = xs.value ? openerRect?.width + 'px' : 'auto'
77
85
  floating.setPosition()
78
86
  }
79
87
  )
@@ -110,7 +118,7 @@ defineExpose({
110
118
  <template>
111
119
  <div>
112
120
  <slot name="opener-start"></slot>
113
- <div ref="opener" class="inline-flex">
121
+ <div ref="opener" class="inline-flex w-full sm:w-auto">
114
122
  <slot name="opener" :isOpen :floating></slot>
115
123
  </div>
116
124
  <slot name="opener-end"></slot>
@@ -118,7 +126,7 @@ defineExpose({
118
126
  v-if="!teleport"
119
127
  ref="content"
120
128
  v-show="$slots.default && (!$slots.opener || ($slots.opener && isOpen))"
121
- class="fz__floating__content"
129
+ class="fz__floating__content w-full sm:w-auto"
122
130
  :class="contentClass"
123
131
  >
124
132
  <slot :isOpen :floating></slot>
@@ -26,6 +26,21 @@ beforeEach(() => {
26
26
  disconnect: () => null
27
27
  })
28
28
  window.IntersectionObserver = mockIntersectionObserver
29
+
30
+ // Mock matchMedia for useMediaQuery composable
31
+ Object.defineProperty(window, 'matchMedia', {
32
+ writable: true,
33
+ value: vi.fn().mockImplementation((query) => ({
34
+ matches: false,
35
+ media: query,
36
+ onchange: null,
37
+ addListener: vi.fn(),
38
+ removeListener: vi.fn(),
39
+ addEventListener: vi.fn(),
40
+ removeEventListener: vi.fn(),
41
+ dispatchEvent: vi.fn()
42
+ }))
43
+ })
29
44
  })
30
45
  describe('FzFloating', () => {
31
46
  positions.forEach((pos) => {
@@ -2,8 +2,8 @@
2
2
 
3
3
  exports[`FzFloating > should match snapshot 1`] = `
4
4
  "<div>
5
- <div class="inline-flex"><button>opener</button></div>
6
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
5
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
6
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
7
7
  <div>content</div>
8
8
  </div>
9
9
  <!--v-if-->
@@ -12,8 +12,8 @@ exports[`FzFloating > should match snapshot 1`] = `
12
12
 
13
13
  exports[`FzFloating > should match snapshot 2`] = `
14
14
  "<div>
15
- <div class="inline-flex"><button>opener</button></div>
16
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
15
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
16
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
17
17
  <div>content</div>
18
18
  </div>
19
19
  <!--v-if-->
@@ -22,8 +22,8 @@ exports[`FzFloating > should match snapshot 2`] = `
22
22
 
23
23
  exports[`FzFloating > should match snapshot 3`] = `
24
24
  "<div>
25
- <div class="inline-flex"><button>opener</button></div>
26
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
25
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
26
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
27
27
  <div>content</div>
28
28
  </div>
29
29
  <!--v-if-->
@@ -32,8 +32,8 @@ exports[`FzFloating > should match snapshot 3`] = `
32
32
 
33
33
  exports[`FzFloating > should match snapshot 4`] = `
34
34
  "<div>
35
- <div class="inline-flex"><button>opener</button></div>
36
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
35
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
36
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
37
37
  <div>content</div>
38
38
  </div>
39
39
  <!--v-if-->
@@ -42,8 +42,8 @@ exports[`FzFloating > should match snapshot 4`] = `
42
42
 
43
43
  exports[`FzFloating > should match snapshot 5`] = `
44
44
  "<div>
45
- <div class="inline-flex"><button>opener</button></div>
46
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
45
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
46
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
47
47
  <div>content</div>
48
48
  </div>
49
49
  <!--v-if-->
@@ -52,8 +52,8 @@ exports[`FzFloating > should match snapshot 5`] = `
52
52
 
53
53
  exports[`FzFloating > should match snapshot 6`] = `
54
54
  "<div>
55
- <div class="inline-flex"><button>opener</button></div>
56
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
55
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
56
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
57
57
  <div>content</div>
58
58
  </div>
59
59
  <!--v-if-->
@@ -62,8 +62,8 @@ exports[`FzFloating > should match snapshot 6`] = `
62
62
 
63
63
  exports[`FzFloating > should match snapshot 7`] = `
64
64
  "<div>
65
- <div class="inline-flex"><button>opener</button></div>
66
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
65
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
66
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
67
67
  <div>content</div>
68
68
  </div>
69
69
  <!--v-if-->
@@ -72,8 +72,8 @@ exports[`FzFloating > should match snapshot 7`] = `
72
72
 
73
73
  exports[`FzFloating > should match snapshot 8`] = `
74
74
  "<div>
75
- <div class="inline-flex"><button>opener</button></div>
76
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
75
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
76
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
77
77
  <div>content</div>
78
78
  </div>
79
79
  <!--v-if-->
@@ -82,8 +82,8 @@ exports[`FzFloating > should match snapshot 8`] = `
82
82
 
83
83
  exports[`FzFloating > should match snapshot 9`] = `
84
84
  "<div>
85
- <div class="inline-flex"><button>opener</button></div>
86
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
85
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
86
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
87
87
  <div>content</div>
88
88
  </div>
89
89
  <!--v-if-->
@@ -92,8 +92,8 @@ exports[`FzFloating > should match snapshot 9`] = `
92
92
 
93
93
  exports[`FzFloating > should match snapshot 10`] = `
94
94
  "<div>
95
- <div class="inline-flex"><button>opener</button></div>
96
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
95
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
96
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
97
97
  <div>content</div>
98
98
  </div>
99
99
  <!--v-if-->
@@ -102,8 +102,8 @@ exports[`FzFloating > should match snapshot 10`] = `
102
102
 
103
103
  exports[`FzFloating > should match snapshot 11`] = `
104
104
  "<div>
105
- <div class="inline-flex"><button>opener</button></div>
106
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
105
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
106
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
107
107
  <div>content</div>
108
108
  </div>
109
109
  <!--v-if-->
@@ -112,8 +112,8 @@ exports[`FzFloating > should match snapshot 11`] = `
112
112
 
113
113
  exports[`FzFloating > should match snapshot 12`] = `
114
114
  "<div>
115
- <div class="inline-flex"><button>opener</button></div>
116
- <div class="fz__floating__content bg-core-white fixed p-4 z-10">
115
+ <div class="inline-flex w-full sm:w-auto"><button>opener</button></div>
116
+ <div class="fz__floating__content w-full sm:w-auto bg-core-white fixed p-4">
117
117
  <div>content</div>
118
118
  </div>
119
119
  <!--v-if-->
@@ -0,0 +1,339 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
2
+ import { ref, toRefs, nextTick } from 'vue'
3
+ import { useFloating } from '../composables/useFloating'
4
+ import { FzFloatingPosition } from '../types'
5
+
6
+ describe('useFloating', () => {
7
+ let mockElement: HTMLElement
8
+ let mockOpener: HTMLElement
9
+ let mockContainer: HTMLElement
10
+ let mockIntersectionObserver: any
11
+
12
+ beforeEach(() => {
13
+ // Mock IntersectionObserver
14
+ mockIntersectionObserver = vi.fn()
15
+ mockIntersectionObserver.mockReturnValue({
16
+ observe: vi.fn(),
17
+ unobserve: vi.fn(),
18
+ disconnect: vi.fn()
19
+ })
20
+ window.IntersectionObserver = mockIntersectionObserver
21
+
22
+ // Mock DOM elements
23
+ mockElement = document.createElement('div')
24
+ mockElement.style.width = '200px'
25
+ mockElement.style.height = '100px'
26
+ document.body.appendChild(mockElement)
27
+
28
+ mockOpener = document.createElement('button')
29
+ mockOpener.style.width = '100px'
30
+ mockOpener.style.height = '40px'
31
+ document.body.appendChild(mockOpener)
32
+
33
+ mockContainer = document.body
34
+
35
+ // Mock getBoundingClientRect
36
+ mockElement.getBoundingClientRect = vi.fn(() => ({
37
+ width: 200,
38
+ height: 100,
39
+ top: 0,
40
+ left: 0,
41
+ right: 200,
42
+ bottom: 100,
43
+ x: 0,
44
+ y: 0,
45
+ toJSON: () => {}
46
+ })) as any
47
+
48
+ mockOpener.getBoundingClientRect = vi.fn(() => ({
49
+ width: 100,
50
+ height: 40,
51
+ top: 100,
52
+ left: 100,
53
+ right: 200,
54
+ bottom: 140,
55
+ x: 100,
56
+ y: 100,
57
+ toJSON: () => {}
58
+ })) as any
59
+
60
+ mockContainer.getBoundingClientRect = vi.fn(() => ({
61
+ width: 1024,
62
+ height: 768,
63
+ top: 0,
64
+ left: 0,
65
+ right: 1024,
66
+ bottom: 768,
67
+ x: 0,
68
+ y: 0,
69
+ toJSON: () => {}
70
+ })) as any
71
+ })
72
+
73
+ afterEach(() => {
74
+ document.body.innerHTML = ''
75
+ vi.clearAllMocks()
76
+ })
77
+
78
+ describe('initialization', () => {
79
+ it('should initialize with correct default values', () => {
80
+ const args = toRefs({
81
+ position: ref<FzFloatingPosition>('auto'),
82
+ opener: { domRef: ref(mockOpener) },
83
+ element: { domRef: ref(mockElement) }
84
+ })
85
+
86
+ const floating = useFloating(args)
87
+
88
+ expect(floating.float).toBeDefined()
89
+ expect(floating.rect).toBeDefined()
90
+ expect(floating.setPosition).toBeDefined()
91
+ expect(floating.position).toBeDefined()
92
+ expect(floating.actualPosition).toBeDefined()
93
+ })
94
+
95
+ it('should set up intersection observer', async () => {
96
+ const args = toRefs({
97
+ position: ref<FzFloatingPosition>('bottom'),
98
+ opener: { domRef: ref(mockOpener) },
99
+ element: { domRef: ref(mockElement) }
100
+ })
101
+
102
+ const floating = useFloating(args)
103
+ await floating.setPosition()
104
+ await nextTick()
105
+
106
+ expect(mockIntersectionObserver).toHaveBeenCalled()
107
+ })
108
+ })
109
+
110
+ describe('position calculation', () => {
111
+ const positions: FzFloatingPosition[] = [
112
+ 'bottom',
113
+ 'bottom-start',
114
+ 'bottom-end',
115
+ 'top',
116
+ 'top-start',
117
+ 'top-end',
118
+ 'left',
119
+ 'left-start',
120
+ 'left-end',
121
+ 'right',
122
+ 'right-start',
123
+ 'right-end'
124
+ ]
125
+
126
+ positions.forEach((position) => {
127
+ it(`should calculate ${position} position correctly`, async () => {
128
+ const args = toRefs({
129
+ position: ref(position),
130
+ element: { domRef: ref(mockElement) },
131
+ opener: { domRef: ref(mockOpener) }
132
+ })
133
+
134
+ const floating = useFloating(args)
135
+ await floating.setPosition()
136
+ await nextTick()
137
+
138
+ expect(floating.float.position).toBeDefined()
139
+ expect(typeof floating.float.position.x).toBe('number')
140
+ expect(typeof floating.float.position.y).toBe('number')
141
+ })
142
+ })
143
+ })
144
+
145
+ describe('auto positioning', () => {
146
+ it('should resolve auto position based on available space', async () => {
147
+ const args = toRefs({
148
+ position: ref<FzFloatingPosition>('auto'),
149
+ element: { domRef: ref(mockElement) },
150
+ opener: { domRef: ref(mockOpener) }
151
+ })
152
+
153
+ const floating = useFloating(args)
154
+ await floating.setPosition()
155
+ await nextTick()
156
+
157
+ expect(floating.actualPosition?.value).toBeDefined()
158
+ expect(floating.actualPosition?.value).not.toBe('auto')
159
+ })
160
+
161
+ it('should resolve auto-start position', async () => {
162
+ const args = toRefs({
163
+ position: ref<FzFloatingPosition>('auto-start'),
164
+ element: { domRef: ref(mockElement) },
165
+ opener: { domRef: ref(mockOpener) }
166
+ })
167
+
168
+ const floating = useFloating(args)
169
+ await floating.setPosition()
170
+ await nextTick()
171
+
172
+ expect(floating.actualPosition?.value).toBeDefined()
173
+ expect(floating.actualPosition?.value).not.toBe('auto-start')
174
+ })
175
+
176
+ it('should resolve auto-end position', async () => {
177
+ const args = toRefs({
178
+ position: ref<FzFloatingPosition>('auto-end'),
179
+ element: { domRef: ref(mockElement) },
180
+ opener: { domRef: ref(mockOpener) }
181
+ })
182
+
183
+ const floating = useFloating(args)
184
+ await floating.setPosition()
185
+ await nextTick()
186
+
187
+ expect(floating.actualPosition?.value).toBeDefined()
188
+ expect(floating.actualPosition?.value).not.toBe('auto-end')
189
+ })
190
+ })
191
+
192
+ describe('boundary corrections', () => {
193
+ it('should keep element within container bounds', async () => {
194
+ // Position opener at edge of container
195
+ mockOpener.getBoundingClientRect = vi.fn(() => ({
196
+ width: 100,
197
+ height: 40,
198
+ top: 10,
199
+ left: 950,
200
+ right: 1050,
201
+ bottom: 50,
202
+ x: 950,
203
+ y: 10,
204
+ toJSON: () => {}
205
+ })) as any
206
+
207
+ const args = toRefs({
208
+ position: ref<FzFloatingPosition>('bottom-end'),
209
+ element: { domRef: ref(mockElement) },
210
+ opener: { domRef: ref(mockOpener) }
211
+ })
212
+
213
+ const floating = useFloating(args)
214
+ await floating.setPosition()
215
+ await nextTick()
216
+
217
+ // Element should be adjusted to fit within container
218
+ expect(floating.float.position.x).toBeLessThanOrEqual(1024)
219
+ expect(floating.float.position.y).toBeLessThanOrEqual(768)
220
+ })
221
+ })
222
+
223
+ describe('element styling', () => {
224
+ it('should apply position fixed immediately', async () => {
225
+ const args = toRefs({
226
+ position: ref<FzFloatingPosition>('bottom'),
227
+ element: { domRef: ref(mockElement) },
228
+ opener: { domRef: ref(mockOpener) }
229
+ })
230
+
231
+ const floating = useFloating(args)
232
+ await floating.setPosition()
233
+ await nextTick()
234
+
235
+ expect(mockElement.style.position).toBe('fixed')
236
+ expect(mockElement.style.display).toBe('flex')
237
+ })
238
+
239
+ it('should update position styles', async () => {
240
+ const args = toRefs({
241
+ position: ref<FzFloatingPosition>('bottom'),
242
+ element: { domRef: ref(mockElement) },
243
+ opener: { domRef: ref(mockOpener) }
244
+ })
245
+
246
+ const floating = useFloating(args)
247
+ await floating.setPosition()
248
+ await nextTick()
249
+
250
+ expect(mockElement.style.top).toBeTruthy()
251
+ expect(mockElement.style.left).toBeTruthy()
252
+ })
253
+ })
254
+
255
+ describe('callback handling', () => {
256
+ it('should call callback when provided', async () => {
257
+ const callback = vi.fn()
258
+ const args = toRefs({
259
+ position: ref<FzFloatingPosition>('bottom'),
260
+ element: { domRef: ref(mockElement) },
261
+ opener: { domRef: ref(mockOpener) },
262
+ callback: ref(callback)
263
+ })
264
+
265
+ const floating = useFloating(args)
266
+ await floating.setPosition()
267
+ await nextTick()
268
+
269
+ expect(callback).toHaveBeenCalled()
270
+ })
271
+
272
+ it('should not call callback when ignoreCallback is true', async () => {
273
+ const callback = vi.fn()
274
+ const args = toRefs({
275
+ position: ref<FzFloatingPosition>('bottom'),
276
+ element: { domRef: ref(mockElement) },
277
+ opener: { domRef: ref(mockOpener) },
278
+ callback: ref(callback)
279
+ })
280
+
281
+ const floating = useFloating(args)
282
+ await floating.setPosition(true)
283
+ await nextTick()
284
+
285
+ expect(callback).not.toHaveBeenCalled()
286
+ })
287
+ })
288
+
289
+ describe('without opener', () => {
290
+ it('should position element relative to container when no opener', async () => {
291
+ const args = toRefs({
292
+ position: ref<FzFloatingPosition>('bottom'),
293
+ opener: undefined,
294
+ element: { domRef: ref(mockElement) }
295
+ })
296
+
297
+ const floating = useFloating(args)
298
+ await floating.setPosition()
299
+ await nextTick()
300
+
301
+ expect(floating.float.position).toBeDefined()
302
+ expect(typeof floating.float.position.x).toBe('number')
303
+ expect(typeof floating.float.position.y).toBe('number')
304
+ })
305
+ })
306
+
307
+ describe('error handling', () => {
308
+ it('should throw error when element ref is missing', async () => {
309
+ const args = toRefs({
310
+ position: ref<FzFloatingPosition>('bottom'),
311
+ opener: { domRef: ref(mockOpener) },
312
+ element: { domRef: ref(null) }
313
+ })
314
+
315
+ const floating = useFloating(args)
316
+
317
+ await expect(async () => {
318
+ await floating.setPosition()
319
+ await nextTick()
320
+ }).rejects.toThrow('missing reference element for floating behavior')
321
+ })
322
+ })
323
+
324
+ describe('cleanup', () => {
325
+ it('should disconnect intersection observer on unmount', () => {
326
+ const args = toRefs({
327
+ position: ref<FzFloatingPosition>('bottom'),
328
+ opener: { domRef: ref(mockOpener) },
329
+ element: { domRef: ref(mockElement) }
330
+ })
331
+
332
+ useFloating(args)
333
+
334
+ // Simulate component unmount
335
+ // Note: In a real scenario, this would be handled by Vue's lifecycle
336
+ expect(mockIntersectionObserver).toHaveBeenCalled()
337
+ })
338
+ })
339
+ })
@@ -7,7 +7,7 @@ export const useFloating = (
7
7
  ): {
8
8
  float: FzRect
9
9
  rect: Ref<DOMRect | undefined>
10
- setPosition: () => Promise<void>
10
+ setPosition: (ignoreCallback?: boolean) => Promise<void>
11
11
  position: Ref<FzFloatingPosition>
12
12
  actualPosition?: Ref<FzFloatingPosition | undefined>
13
13
  openerRect: Ref<DOMRect | undefined>
@@ -38,272 +38,349 @@ export const useFloating = (
38
38
  const floatObserver = ref(new IntersectionObserver(handleIntersect, options))
39
39
  const actualPosition = ref<FzFloatingPosition>()
40
40
 
41
- const setPosition = (ignoreCallback: boolean = false) =>
42
- nextTick(() => {
43
- actualPosition.value = args.position ? args.position.value : 'auto'
44
- safeElementDomRef.value = (
45
- typeof args.element.value.domRef.value === 'string'
46
- ? document.querySelector(args.element.value.domRef.value)
47
- : args.element.value.domRef.value
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
48
56
  ) as HTMLElement
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
57
+ safeContainerDomRef.value ??= document.body
58
+ }
59
+
60
+ if (!safeElementDomRef.value) {
61
+ throw new Error('missing reference element for floating behavior')
62
+ }
63
+
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
+ }
71
+
72
+ rect.value = safeElementDomRef.value.getBoundingClientRect()
73
+ openerRect.value = safeOpenerDomRef.value?.getBoundingClientRect()
74
+ containerRect.value = safeContainerDomRef.value.getBoundingClientRect()
75
+ }
76
+
77
+ // Helper: Resolve auto position to actual position
78
+ const resolveAutoPositionValue = () => {
79
+ if (!args.opener?.value || !safeOpenerDomRef.value) return
80
+
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
+ }
135
+ }
136
+
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
227
+ }
228
+ }
229
+
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
+ }
278
+ }
279
+
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
291
+ }
292
+
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
58
297
  }
298
+ translateX.value = 0
299
+ }
300
+
301
+ if (realPos.y < containerRect.value.top) {
302
+ float.position.y = containerRect.value.top
303
+ translateY.value = 0
304
+ }
59
305
 
60
- if (!safeElementDomRef.value) {
61
- throw new Error('missing reference element for floating behavior')
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
62
310
  }
311
+ translateY.value = 0
312
+ }
313
+ }
314
+
315
+ // Helper: Apply element styles
316
+ const applyElementStyles = () => {
317
+ if (!safeElementDomRef.value) return
318
+
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
+ }
324
+
325
+ const setPosition = (ignoreCallback: boolean = false) =>
326
+ nextTick(() => {
327
+ actualPosition.value = args.position ? args.position.value : 'auto'
328
+
329
+ // Step 1: Resolve DOM references
330
+ resolveDomReferences()
63
331
 
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
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'
70
338
  }
71
339
 
72
- rect.value = safeElementDomRef.value.getBoundingClientRect()
340
+ // Recalculate all rects after content is rendered
341
+ rect.value = safeElementDomRef.value!.getBoundingClientRect()
73
342
  openerRect.value = safeOpenerDomRef.value?.getBoundingClientRect()
74
- containerRect.value = safeContainerDomRef.value.getBoundingClientRect()
343
+ containerRect.value = safeContainerDomRef.value!.getBoundingClientRect()
75
344
 
76
345
  const elStyle = window.getComputedStyle(safeElementDomRef.value as HTMLElement)
77
346
 
78
- // multiple observer calls on same target do not cause multiple registrations
347
+ // Step 2: Setup intersection observer
79
348
  floatObserver.value.observe(safeElementDomRef.value as HTMLElement)
80
349
  floatObserver.value.observe(safeContainerDomRef.value as HTMLElement)
81
350
 
82
- let translateY = 0
83
- let translateX = 0
351
+ // Step 3: Resolve auto positions
352
+ resolveAutoPositionValue()
353
+
354
+ // Step 4: Calculate position based on opener presence
355
+ const translate = { x: { value: 0 }, y: { value: 0 } }
84
356
 
85
357
  if (args.opener?.value && safeOpenerDomRef.value && openerRect?.value) {
86
- const leftWithoutXMargin =
87
- openerRect.value.left - parseFloat(elStyle.marginLeft) - parseFloat(elStyle.marginRight)
88
- const leftWithoutLeftMargin = openerRect.value.left - parseFloat(elStyle.marginLeft)
89
- const topWithoutYMargin =
90
- openerRect.value.top - parseFloat(elStyle.marginTop) - parseFloat(elStyle.marginBottom)
91
- const topWithoutTopMargin = openerRect.value.top - parseFloat(elStyle.marginTop)
92
-
93
- switch (actualPosition.value) {
94
- case 'auto':
95
- actualPosition.value = getHighestAvailableSpacePos(
96
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
97
- safeElementDomRef.value as HTMLElement,
98
- safeOpenerDomRef.value as HTMLElement
99
- )
100
- break
101
- case 'auto-vertical':
102
- actualPosition.value = getHighestAvailableSpacePos(
103
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
104
- safeElementDomRef.value as HTMLElement,
105
- safeOpenerDomRef.value as HTMLElement,
106
- undefined,
107
- true
108
- )
109
- break
110
- case 'auto-start':
111
- actualPosition.value = getHighestAvailableSpacePos(
112
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
113
- safeElementDomRef.value as HTMLElement,
114
- safeOpenerDomRef.value as HTMLElement,
115
- 'start'
116
- )
117
- break
118
- case 'auto-vertical-start':
119
- actualPosition.value = getHighestAvailableSpacePos(
120
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
121
- safeElementDomRef.value as HTMLElement,
122
- safeOpenerDomRef.value as HTMLElement,
123
- 'start',
124
- true
125
- )
126
- break
127
- case 'auto-end':
128
- actualPosition.value = getHighestAvailableSpacePos(
129
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
130
- safeElementDomRef.value as HTMLElement,
131
- safeOpenerDomRef.value as HTMLElement,
132
- 'end'
133
- )
134
- break
135
- case 'auto-vertical-end':
136
- actualPosition.value = getHighestAvailableSpacePos(
137
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
138
- safeElementDomRef.value as HTMLElement,
139
- safeOpenerDomRef.value as HTMLElement,
140
- 'end',
141
- true
142
- )
143
- break
144
- default:
145
- break
146
- }
147
- switch (actualPosition.value) {
148
- case 'bottom':
149
- float.position.y = openerRect.value.bottom
150
- float.position.x = leftWithoutLeftMargin + openerRect.value.width / 2
151
- translateX = -50
152
- translateY = 0
153
- break
154
- case 'bottom-start':
155
- float.position.y = openerRect.value.bottom
156
- float.position.x = leftWithoutXMargin
157
- translateX = 0
158
- translateY = 0
159
- break
160
- case 'bottom-end':
161
- float.position.y = openerRect.value.bottom
162
- float.position.x = openerRect.value.right
163
- translateX = -100
164
- translateY = 0
165
- break
166
- case 'left-start':
167
- float.position.y = topWithoutYMargin
168
- float.position.x = leftWithoutXMargin
169
- translateX = -100
170
- translateY = 0
171
- break
172
- case 'left':
173
- float.position.y = topWithoutTopMargin + openerRect.value.height / 2
174
- float.position.x = leftWithoutXMargin
175
- translateY = -50
176
- translateX = -100
177
- break
178
- case 'left-end':
179
- float.position.y = openerRect.value.bottom
180
- float.position.x = leftWithoutXMargin
181
- translateY = -100
182
- translateX = -100
183
- break
184
- case 'top-start':
185
- float.position.y = topWithoutYMargin
186
- float.position.x = leftWithoutXMargin
187
- translateY = -100
188
- translateX = 0
189
- break
190
- case 'top':
191
- float.position.y = topWithoutYMargin
192
- float.position.x = leftWithoutLeftMargin + openerRect.value.width / 2
193
- translateX = -50
194
- translateY = -100
195
- break
196
- case 'top-end':
197
- float.position.y = topWithoutYMargin
198
- float.position.x = openerRect.value.right
199
- translateX = -100
200
- translateY = -100
201
- break
202
- case 'right-start':
203
- float.position.y = topWithoutYMargin
204
- float.position.x = openerRect.value.right
205
- translateX = 0
206
- translateY = 0
207
- break
208
- case 'right':
209
- float.position.y = topWithoutTopMargin + openerRect.value.height / 2
210
- float.position.x = openerRect.value.right
211
- translateY = -50
212
- translateX = 0
213
- break
214
- case 'right-end':
215
- float.position.y = openerRect.value.bottom
216
- float.position.x = openerRect.value.right
217
- translateY = -100
218
- translateX = 0
219
- break
220
- default:
221
- break
222
- }
358
+ calculatePositionWithOpener(elStyle, translate.x, translate.y)
223
359
  } else {
224
- switch (actualPosition.value) {
225
- case 'bottom':
226
- float.position.y = containerRect.value.bottom - rect.value.height
227
- float.position.x = containerRect.value.left + containerRect.value.width / 2
228
- translateX = -50
229
- break
230
- case 'left-end':
231
- case 'bottom-start':
232
- float.position.y = containerRect.value.bottom - rect.value.height
233
- float.position.x = containerRect.value.left
234
- break
235
- case 'right-end':
236
- case 'bottom-end':
237
- float.position.y = containerRect.value.bottom - rect.value.height
238
- float.position.x = containerRect.value.right - rect.value.width
239
- break
240
- case 'left':
241
- float.position.y =
242
- containerRect.value.top + (containerRect.value.height - rect.value.height) / 2
243
- float.position.x = containerRect.value.left
244
- break
245
- case 'top-start':
246
- case 'left-start':
247
- float.position.y = containerRect.value.top
248
- float.position.x = containerRect.value.left
249
- break
250
- case 'top':
251
- float.position.y = containerRect.value.top
252
- float.position.x =
253
- containerRect.value.left + (containerRect.value.width - rect.value.width) / 2
254
- break
255
- case 'top-end':
256
- case 'right-start':
257
- float.position.y = containerRect.value.top
258
- float.position.x = containerRect.value.right - rect.value.width
259
- case 'right':
260
- float.position.y =
261
- containerRect.value.top + (containerRect.value.height - rect.value.height) / 2
262
- float.position.x = containerRect.value.right - rect.value.width
263
- break
264
- default:
265
- break
266
- }
267
- }
268
-
269
- const realPos = calcRealPos(rect.value, float.position, translateX, translateY)
270
-
271
- float.position.x = realPos.x
272
- float.position.y = realPos.y
273
-
274
- if (realPos.x < containerRect.value.left) {
275
- float.position.x = containerRect.value.left
276
- translateX = 0
277
- }
278
-
279
- if (realPos.x + rect.value.width > containerRect.value.right) {
280
- const fixVal = containerRect.value.right - rect.value.width
281
- if (fixVal > 0) {
282
- float.position.x = fixVal
283
- }
284
- translateX = 0
360
+ calculatePositionWithoutOpener(translate.x)
285
361
  }
286
362
 
287
- if (realPos.y < containerRect.value.top) {
288
- float.position.y = containerRect.value.top
289
- translateY = 0
290
- }
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
291
373
 
292
- if (realPos.y + rect.value.height > containerRect.value.bottom) {
293
- const fixVal = containerRect.value.bottom - rect.value.height
294
- if (fixVal > 0) {
295
- float.position.y = fixVal
296
- }
297
- translateY = 0
374
+ applyBoundaryCorrections(realPos, translate.x, translate.y)
298
375
  }
299
376
 
300
- safeElementDomRef.value.style.top = `${float.position.y}px`
301
- safeElementDomRef.value.style.left = `${float.position.x}px`
302
- safeElementDomRef.value.style.position = 'fixed'
303
- safeElementDomRef.value.style.display = 'flex'
377
+ // Step 6: Apply styles to element
378
+ applyElementStyles()
304
379
 
305
- rect.value = safeElementDomRef.value.getBoundingClientRect()
380
+ // Step 7: Update rect after positioning
381
+ rect.value = safeElementDomRef.value!.getBoundingClientRect()
306
382
 
383
+ // Step 8: Trigger callback if provided
307
384
  if (args.callback?.value && !ignoreCallback) {
308
385
  args.callback.value(rect, openerRect, containerRect, position, actualPosition)
309
386
  }
@@ -1,4 +1,4 @@
1
- import { Ref, onMounted, onUnmounted, ref } from 'vue'
1
+ import { Ref, onMounted, onUnmounted, ref, computed } from 'vue'
2
2
 
3
3
  function useMediaQuery(mediaQuery: string): Ref<boolean> {
4
4
  const mediaQueryList = window.matchMedia(mediaQuery)
@@ -16,7 +16,7 @@ function useMediaQuery(mediaQuery: string): Ref<boolean> {
16
16
  mediaQueryList.removeEventListener('change', handleChange)
17
17
  })
18
18
 
19
- return matches
19
+ return computed(() => matches.value)
20
20
  }
21
21
 
22
22
  export { useMediaQuery }
package/src/index.ts CHANGED
@@ -2,4 +2,4 @@ export { default as FzFloating } from './FzFloating.vue'
2
2
 
3
3
  export * from './composables'
4
4
  export * from './types'
5
- export * from './utils'
5
+ export * from './utils'
package/src/types.ts CHANGED
@@ -40,7 +40,7 @@ export interface FzFloatElement {
40
40
  export interface FzUseFloatingArgs {
41
41
  element: FzFloatElement
42
42
  container?: FzFloatElement
43
- opener: FzFloatElement
43
+ opener?: FzFloatElement
44
44
  position?: FzFloatingPosition
45
45
  useViewport?: boolean
46
46
  callback?: (