@fiscozen/composables 0.1.38 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/FzFloating.vue +115 -8
- package/src/composables/useCurrency.ts +7 -113
- package/src/composables/useFloating.ts +534 -345
- package/src/utils/number/index.ts +70 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiscozen/composables",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Design System utility composables",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"vite": "^5.0.10",
|
|
29
29
|
"vitest": "^1.2.0",
|
|
30
30
|
"vue-tsc": "^1.8.25",
|
|
31
|
+
"@fiscozen/prettier-config": "^0.1.0",
|
|
31
32
|
"@fiscozen/tsconfig": "^0.1.0",
|
|
32
|
-
"@fiscozen/eslint-config": "^0.1.0"
|
|
33
|
-
"@fiscozen/prettier-config": "^0.1.0"
|
|
33
|
+
"@fiscozen/eslint-config": "^0.1.0"
|
|
34
34
|
},
|
|
35
35
|
"license": "ISC",
|
|
36
36
|
"scripts": {
|
package/src/FzFloating.vue
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* FzFloating Component
|
|
4
|
+
*
|
|
5
|
+
* A floating container that positions itself relative to an opener element.
|
|
6
|
+
* Supports automatic repositioning on scroll, resize, and content changes.
|
|
7
|
+
*
|
|
8
|
+
* ## Features
|
|
9
|
+
* - 12 positioning options (top/bottom/left/right with start/center/end alignment)
|
|
10
|
+
* - Auto-positioning based on available viewport space
|
|
11
|
+
* - Reactive repositioning via ResizeObserver
|
|
12
|
+
* - Teleport support for z-index stacking context issues
|
|
13
|
+
* - Automatic margin based on resolved position direction
|
|
14
|
+
*
|
|
15
|
+
* ## Slots
|
|
16
|
+
* - `opener`: The element that triggers/anchors the floating content
|
|
17
|
+
* - `default`: The floating content to display
|
|
18
|
+
*
|
|
19
|
+
* ## Example
|
|
20
|
+
* ```vue
|
|
21
|
+
* <FzFloating position="bottom-start" :isOpen="showMenu">
|
|
22
|
+
* <template #opener>
|
|
23
|
+
* <button @click="showMenu = !showMenu">Menu</button>
|
|
24
|
+
* </template>
|
|
25
|
+
* <div>Menu content here</div>
|
|
26
|
+
* </FzFloating>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
import { ref, useSlots, watch, toRef, computed, onBeforeUnmount, onMounted, toRefs } from 'vue'
|
|
3
30
|
import { useFloating } from './composables'
|
|
4
31
|
import { FzFloatingProps, FzUseFloatingArgs } from './types'
|
|
5
32
|
import { useMediaQuery } from './composables'
|
|
@@ -21,6 +48,11 @@ const slots = useSlots()
|
|
|
21
48
|
const xs = useMediaQuery(`(max-width: ${breakpoints.xs})`)
|
|
22
49
|
|
|
23
50
|
let scheduledAnimationFrame = false
|
|
51
|
+
let isMounted = false
|
|
52
|
+
|
|
53
|
+
// Observers for reactive repositioning
|
|
54
|
+
let resizeObserver: ResizeObserver | null = null
|
|
55
|
+
let openerResizeObserver: ResizeObserver | null = null
|
|
24
56
|
|
|
25
57
|
const useFloatingOpts: FzUseFloatingArgs = {
|
|
26
58
|
position: props.position,
|
|
@@ -52,17 +84,72 @@ if (slots.opener) {
|
|
|
52
84
|
const floating = useFloating(dynamicOpts)
|
|
53
85
|
|
|
54
86
|
const setPositionWhenOpen = () => {
|
|
55
|
-
if (scheduledAnimationFrame) {
|
|
87
|
+
if (scheduledAnimationFrame || !isMounted) {
|
|
56
88
|
return
|
|
57
89
|
}
|
|
58
90
|
|
|
59
91
|
scheduledAnimationFrame = true
|
|
60
92
|
requestAnimationFrame(() => {
|
|
61
|
-
|
|
93
|
+
if (isMounted && props.isOpen) {
|
|
94
|
+
floating.setPosition()
|
|
95
|
+
}
|
|
62
96
|
scheduledAnimationFrame = false
|
|
63
97
|
})
|
|
64
98
|
}
|
|
65
99
|
|
|
100
|
+
onMounted(() => {
|
|
101
|
+
isMounted = true
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Scroll handler that catches scroll events on any scrollable parent
|
|
105
|
+
const handleScroll = () => {
|
|
106
|
+
setPositionWhenOpen()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Window resize handler
|
|
110
|
+
const handleResize = () => {
|
|
111
|
+
setPositionWhenOpen()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Setup all event listeners and observers
|
|
115
|
+
const setupEventListeners = () => {
|
|
116
|
+
// Window scroll and resize
|
|
117
|
+
window.addEventListener('scroll', handleScroll, true) // capture phase for nested scrollables
|
|
118
|
+
window.addEventListener('resize', handleResize)
|
|
119
|
+
|
|
120
|
+
// ResizeObserver for floating content (handles content size changes)
|
|
121
|
+
if (content.value && !resizeObserver) {
|
|
122
|
+
resizeObserver = new ResizeObserver(() => {
|
|
123
|
+
setPositionWhenOpen()
|
|
124
|
+
})
|
|
125
|
+
resizeObserver.observe(content.value)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ResizeObserver for opener (handles opener size/position changes)
|
|
129
|
+
if (opener.value && !openerResizeObserver) {
|
|
130
|
+
openerResizeObserver = new ResizeObserver(() => {
|
|
131
|
+
setPositionWhenOpen()
|
|
132
|
+
})
|
|
133
|
+
openerResizeObserver.observe(opener.value)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Cleanup all event listeners and observers
|
|
138
|
+
const cleanupEventListeners = () => {
|
|
139
|
+
window.removeEventListener('scroll', handleScroll, true)
|
|
140
|
+
window.removeEventListener('resize', handleResize)
|
|
141
|
+
|
|
142
|
+
if (resizeObserver) {
|
|
143
|
+
resizeObserver.disconnect()
|
|
144
|
+
resizeObserver = null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (openerResizeObserver) {
|
|
148
|
+
openerResizeObserver.disconnect()
|
|
149
|
+
openerResizeObserver = null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
66
153
|
watch(
|
|
67
154
|
() => props.position,
|
|
68
155
|
() => setPositionWhenOpen()
|
|
@@ -70,11 +157,13 @@ watch(
|
|
|
70
157
|
watch(
|
|
71
158
|
() => props.isOpen,
|
|
72
159
|
(newVal) => {
|
|
73
|
-
if (!newVal || !content.value) {
|
|
74
|
-
|
|
160
|
+
if (!newVal || !content.value || !isMounted) {
|
|
161
|
+
cleanupEventListeners()
|
|
75
162
|
return
|
|
76
163
|
}
|
|
77
|
-
|
|
164
|
+
|
|
165
|
+
setupEventListeners()
|
|
166
|
+
|
|
78
167
|
const openerRect = opener.value?.getBoundingClientRect()
|
|
79
168
|
// CRITICAL: Set position fixed immediately to prevent layout shift
|
|
80
169
|
content.value.style.position = 'fixed'
|
|
@@ -98,8 +187,26 @@ watch(
|
|
|
98
187
|
}
|
|
99
188
|
}
|
|
100
189
|
)
|
|
190
|
+
|
|
101
191
|
onBeforeUnmount(() => {
|
|
102
|
-
|
|
192
|
+
isMounted = false
|
|
193
|
+
cleanupEventListeners()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// Helper function to get margin class from position
|
|
197
|
+
const getMarginClassForPosition = (position: string): string => {
|
|
198
|
+
if (position.startsWith('bottom')) return 'mt-4' // margin-top only
|
|
199
|
+
if (position.startsWith('top')) return 'mb-4' // margin-bottom only
|
|
200
|
+
if (position.startsWith('left')) return 'mr-4' // margin-right only
|
|
201
|
+
if (position.startsWith('right')) return 'ml-4' // margin-left only
|
|
202
|
+
return 'mt-4' // default fallback
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Computed margin class based on resolved position
|
|
206
|
+
// Uses actualPosition for auto positions, falls back to props.position for explicit
|
|
207
|
+
const resolvedMarginClass = computed(() => {
|
|
208
|
+
const effectivePosition = floating.actualPosition?.value ?? props.position
|
|
209
|
+
return getMarginClassForPosition(effectivePosition)
|
|
103
210
|
})
|
|
104
211
|
|
|
105
212
|
const contentClass = computed(() => {
|
|
@@ -107,7 +214,7 @@ const contentClass = computed(() => {
|
|
|
107
214
|
return props.contentClass
|
|
108
215
|
}
|
|
109
216
|
|
|
110
|
-
return ['bg-core-white fixed
|
|
217
|
+
return ['bg-core-white fixed', resolvedMarginClass.value, props.contentClass]
|
|
111
218
|
})
|
|
112
219
|
|
|
113
220
|
defineExpose({
|
|
@@ -1,119 +1,13 @@
|
|
|
1
|
-
import { Ref, watch, getCurrentInstance, computed, ref, nextTick } from 'vue'
|
|
2
1
|
import { FzUseCurrencyOptions } from '../types'
|
|
3
|
-
import { format
|
|
4
|
-
|
|
5
|
-
export const useCurrency = (options: FzUseCurrencyOptions) => {
|
|
6
|
-
const inputRef: Ref<HTMLInputElement | null | undefined> = ref(null)
|
|
7
|
-
const vm = getCurrentInstance()
|
|
8
|
-
|
|
9
|
-
const computedModel = computed<number | null>(() => vm?.props.amount as number | null)
|
|
10
|
-
const internalVal = ref<number | null>()
|
|
11
|
-
|
|
12
|
-
const format = formatNumber(options)
|
|
13
|
-
|
|
14
|
-
const emitAmount = (val: number | null) => {
|
|
15
|
-
if (Number.isNaN(val)) {
|
|
16
|
-
return
|
|
17
|
-
}
|
|
18
|
-
if (vm) {
|
|
19
|
-
internalVal.value = val
|
|
20
|
-
if (vm.props.nullOnEmpty && !val) {
|
|
21
|
-
vm.emit('update:amount', null)
|
|
22
|
-
return
|
|
23
|
-
}
|
|
24
|
-
vm.emit('update:amount', val)
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const setValue = (val: string) => {
|
|
29
|
-
// nextTick doesn't seem to work
|
|
30
|
-
setTimeout(() => {
|
|
31
|
-
if (!inputRef.value) {
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
inputRef.value.value = val
|
|
35
|
-
}, 0)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const onInput = (el: HTMLInputElement) => (e: Event) => {
|
|
39
|
-
if (!inputRef.value || !e.target) {
|
|
40
|
-
return
|
|
41
|
-
}
|
|
42
|
-
let { value } = el
|
|
43
|
-
value = value.replace(/[^0-9,.-]/g, '')
|
|
44
|
-
const parsed: number = parse(value);
|
|
45
|
-
|
|
46
|
-
setValue(value)
|
|
47
|
-
const numberValue = vm?.props.nullOnEmpty && value === '' ? null : parsed
|
|
48
|
-
emitAmount(Number.isNaN(numberValue) ? 0 : numberValue)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const onBlur = (e: FocusEvent) => {
|
|
52
|
-
if (!inputRef.value || !e.target) {
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
const rawValue = (e.target as HTMLInputElement).value.replace(/,/g, '.')
|
|
56
|
-
let number: number | null
|
|
57
|
-
|
|
58
|
-
if (rawValue === '' && vm?.props.nullOnEmpty) {
|
|
59
|
-
number = null
|
|
60
|
-
} else {
|
|
61
|
-
number = parse(rawValue)
|
|
62
|
-
if (Number.isNaN(number)) {
|
|
63
|
-
number = 0
|
|
64
|
-
}
|
|
65
|
-
if (options.step && vm?.props.forceStep) {
|
|
66
|
-
number = roundTo(options.step, number)
|
|
67
|
-
}
|
|
68
|
-
if (options.min && options.min > number) {
|
|
69
|
-
number = options.min
|
|
70
|
-
}
|
|
71
|
-
if (options.max && options.max < number) {
|
|
72
|
-
number = options.max
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const text = format(number)
|
|
76
|
-
setValue(text)
|
|
77
|
-
emitAmount(parse(text))
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
watch(inputRef, (newVal, oldVal) => {
|
|
81
|
-
if (!newVal) {
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (oldVal) {
|
|
86
|
-
oldVal?.removeEventListener('input', onInput(newVal))
|
|
87
|
-
oldVal?.removeEventListener('blur', onBlur)
|
|
88
|
-
}
|
|
89
|
-
newVal.addEventListener('input', onInput(newVal))
|
|
90
|
-
newVal.addEventListener('blur', onBlur)
|
|
91
|
-
|
|
92
|
-
if (vm?.props.amount) {
|
|
93
|
-
newVal.value = format(computedModel.value)
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
watch(computedModel, (newVal) => {
|
|
98
|
-
nextTick(() => {
|
|
99
|
-
if (!inputRef.value || newVal === null || newVal === undefined) {
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
// we need to format here only if someone externally set the
|
|
103
|
-
// value of the amount model
|
|
104
|
-
if (internalVal.value !== newVal) {
|
|
105
|
-
const formatted = format(newVal)
|
|
106
|
-
inputRef.value.value = formatted
|
|
107
|
-
internalVal.value = newVal
|
|
108
|
-
}
|
|
109
|
-
})
|
|
110
|
-
})
|
|
2
|
+
import { format, parse } from '../utils'
|
|
111
3
|
|
|
4
|
+
/**
|
|
5
|
+
* @deprecated This composable is deprecated.
|
|
6
|
+
* You can use `format` and `parse` directly from `composables/utils`.
|
|
7
|
+
*/
|
|
8
|
+
export const useCurrency = (_options: FzUseCurrencyOptions) => {
|
|
112
9
|
return {
|
|
113
|
-
inputRef,
|
|
114
|
-
parse,
|
|
115
10
|
format,
|
|
116
|
-
|
|
117
|
-
setValue
|
|
11
|
+
parse
|
|
118
12
|
}
|
|
119
13
|
}
|
|
@@ -1,391 +1,580 @@
|
|
|
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
|
-
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))
|
|
373
470
|
|
|
374
|
-
|
|
375
|
-
|
|
471
|
+
// DOM Reference Resolution
|
|
472
|
+
const resolveDOMRefs = (): DOMRefs | null => {
|
|
473
|
+
const element = resolveElement(args.element.value.domRef.value)
|
|
474
|
+
if (!element) {
|
|
475
|
+
throw new Error('missing reference element for floating behavior')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const container = args.container?.value
|
|
479
|
+
? resolveElement(args.container.value.domRef.value) ?? document.body
|
|
480
|
+
: document.body
|
|
376
481
|
|
|
377
|
-
|
|
378
|
-
|
|
482
|
+
const opener = args.opener?.value
|
|
483
|
+
? resolveElement(args.opener.value.domRef.value)
|
|
484
|
+
: null
|
|
379
485
|
|
|
380
|
-
|
|
381
|
-
|
|
486
|
+
return { element, container, opener }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const getRects = (refs: DOMRefs): Rects => ({
|
|
490
|
+
element: refs.element.getBoundingClientRect(),
|
|
491
|
+
container: refs.container.getBoundingClientRect(),
|
|
492
|
+
opener: refs.opener?.getBoundingClientRect() ?? null
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
// Main positioning function
|
|
496
|
+
const setPosition = (ignoreCallback: boolean = false) =>
|
|
497
|
+
nextTick(() => {
|
|
498
|
+
// Step 1: Initialize position from args
|
|
499
|
+
actualPosition.value = args.position?.value ?? 'auto'
|
|
500
|
+
|
|
501
|
+
// Step 2: Resolve DOM references
|
|
502
|
+
const refs = resolveDOMRefs()
|
|
503
|
+
if (!refs) return
|
|
504
|
+
|
|
505
|
+
// Step 3: Set fixed positioning immediately to prevent layout shift
|
|
506
|
+
Object.assign(refs.element.style, {
|
|
507
|
+
position: 'fixed',
|
|
508
|
+
top: '0px',
|
|
509
|
+
left: '0px'
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// Step 4: Get all bounding rects
|
|
513
|
+
const rects = getRects(refs)
|
|
514
|
+
rect.value = rects.element
|
|
515
|
+
openerRect.value = rects.opener ?? undefined
|
|
516
|
+
containerRect.value = rects.container
|
|
517
|
+
|
|
518
|
+
// Step 5: Setup observers
|
|
519
|
+
floatObserver.value.observe(refs.element)
|
|
520
|
+
floatObserver.value.observe(refs.container)
|
|
521
|
+
|
|
522
|
+
// Step 6: Resolve auto position if needed
|
|
523
|
+
if (isAutoPosition(actualPosition.value) && refs.opener) {
|
|
524
|
+
actualPosition.value = resolveAutoPosition(
|
|
525
|
+
actualPosition.value,
|
|
526
|
+
refs.container,
|
|
527
|
+
refs.element,
|
|
528
|
+
refs.opener,
|
|
529
|
+
args.useViewport?.value ?? false
|
|
530
|
+
)
|
|
531
|
+
}
|
|
382
532
|
|
|
383
|
-
// Step
|
|
533
|
+
// Step 7: Calculate position
|
|
534
|
+
const margins = getMargins(window.getComputedStyle(refs.element))
|
|
535
|
+
|
|
536
|
+
const positionResult = refs.opener && rects.opener
|
|
537
|
+
? calculatePositionWithOpener(actualPosition.value!, rects.opener, margins)
|
|
538
|
+
: calculatePositionWithoutOpener(actualPosition.value!, rects.container, rects.element)
|
|
539
|
+
|
|
540
|
+
// Step 8: Apply transform to get real position
|
|
541
|
+
const realPosition = calcRealPos(
|
|
542
|
+
rects.element,
|
|
543
|
+
positionResult.position,
|
|
544
|
+
positionResult.transform.x,
|
|
545
|
+
positionResult.transform.y
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
// Step 9: Apply boundary corrections
|
|
549
|
+
const corrected = applyBoundaryCorrections(
|
|
550
|
+
realPosition,
|
|
551
|
+
rects.element,
|
|
552
|
+
rects.container,
|
|
553
|
+
positionResult.transform
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
// Step 10: Update float state
|
|
557
|
+
float.position.x = corrected.position.x
|
|
558
|
+
float.position.y = corrected.position.y
|
|
559
|
+
|
|
560
|
+
// Step 11: Apply final styles
|
|
561
|
+
Object.assign(refs.element.style, {
|
|
562
|
+
top: `${float.position.y}px`,
|
|
563
|
+
left: `${float.position.x}px`,
|
|
564
|
+
position: 'fixed',
|
|
565
|
+
display: 'flex'
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
// Step 12: Update rect after final positioning
|
|
569
|
+
rect.value = refs.element.getBoundingClientRect()
|
|
570
|
+
|
|
571
|
+
// Step 13: Trigger callback if provided
|
|
384
572
|
if (args.callback?.value && !ignoreCallback) {
|
|
385
573
|
args.callback.value(rect, openerRect, containerRect, position, actualPosition)
|
|
386
574
|
}
|
|
387
575
|
})
|
|
388
576
|
|
|
577
|
+
// Cleanup
|
|
389
578
|
onUnmounted(() => {
|
|
390
579
|
floatObserver.value.disconnect()
|
|
391
580
|
})
|
|
@@ -1,21 +1,74 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Truncates a number to the specified maximum decimal places (without rounding)
|
|
3
|
+
*
|
|
4
|
+
* @param value - Number to truncate
|
|
5
|
+
* @param maxDecimals - Maximum number of decimal places
|
|
6
|
+
* @returns Truncated number
|
|
7
|
+
*/
|
|
8
|
+
export const truncateDecimals = (value: number, maxDecimals: number): number => {
|
|
9
|
+
const factor = Math.pow(10, maxDecimals);
|
|
10
|
+
return Math.trunc(value * factor) / factor;
|
|
11
|
+
};
|
|
2
12
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Formats a number value using provided options
|
|
15
|
+
*
|
|
16
|
+
* Uses Italian locale format (comma as decimal separator, point as thousand separator).
|
|
17
|
+
* Can truncate (not round) decimal places to maximumFractionDigits before formatting
|
|
18
|
+
* based on roundDecimals option.
|
|
19
|
+
*
|
|
20
|
+
* @param value - Number value to format
|
|
21
|
+
* @param options - Formatting options
|
|
22
|
+
* @param options.minimumFractionDigits - Minimum decimal places (default: 0)
|
|
23
|
+
* @param options.maximumFractionDigits - Maximum decimal places (default: 2)
|
|
24
|
+
* @param options.roundDecimals - If true, rounds decimals; if false, truncates (default: true)
|
|
25
|
+
* @param options.useGrouping - Whether to use thousand separators (default: true)
|
|
26
|
+
* @returns Formatted string (e.g., "1.234,56")
|
|
27
|
+
*/
|
|
28
|
+
export const format = (
|
|
29
|
+
value: number | null | undefined,
|
|
30
|
+
{
|
|
31
|
+
minimumFractionDigits = 0,
|
|
32
|
+
maximumFractionDigits = 2,
|
|
33
|
+
roundDecimals = true,
|
|
34
|
+
useGrouping = true,
|
|
35
|
+
} = {}
|
|
36
|
+
): string => {
|
|
37
|
+
if (
|
|
38
|
+
value === undefined ||
|
|
39
|
+
value === null ||
|
|
40
|
+
isNaN(value) ||
|
|
41
|
+
!isFinite(value)
|
|
42
|
+
) {
|
|
43
|
+
return "";
|
|
11
44
|
}
|
|
12
45
|
|
|
13
|
-
|
|
14
|
-
|
|
46
|
+
// Truncate decimals if roundDecimals is false, otherwise use value as-is (toLocaleString will round)
|
|
47
|
+
const processedValue = roundDecimals
|
|
48
|
+
? value
|
|
49
|
+
: truncateDecimals(value, maximumFractionDigits);
|
|
50
|
+
|
|
51
|
+
return processedValue.toLocaleString("it-IT", {
|
|
52
|
+
minimumFractionDigits: minimumFractionDigits,
|
|
53
|
+
maximumFractionDigits: maximumFractionDigits,
|
|
54
|
+
useGrouping: useGrouping,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
15
57
|
|
|
16
58
|
export const parse = (text: string): number => {
|
|
17
|
-
|
|
18
|
-
|
|
59
|
+
if (!text || typeof text !== 'string') {
|
|
60
|
+
return NaN
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let normalized = text.trim()
|
|
64
|
+
|
|
65
|
+
// Handle Italian format: "1.234,56" (points = thousands, comma = decimal)
|
|
66
|
+
if (normalized.includes(',')) {
|
|
67
|
+
normalized = normalized.replace(/\./g, '')
|
|
68
|
+
normalized = normalized.replace(',', '.')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return parseFloat(normalized)
|
|
19
72
|
}
|
|
20
73
|
|
|
21
74
|
export const roundTo = (step: number, val: number) => {
|
|
@@ -26,4 +79,8 @@ export const roundTo = (step: number, val: number) => {
|
|
|
26
79
|
result = Math.abs(remainder) >= step / 2 ? val + safeStep - remainder : val - remainder
|
|
27
80
|
}
|
|
28
81
|
return result
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const clamp = (min: number, value: number, max: number): number => {
|
|
85
|
+
return Math.max(min, Math.min(value, max))
|
|
29
86
|
}
|