@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiscozen/composables",
3
- "version": "0.1.38",
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": {
@@ -1,5 +1,32 @@
1
1
  <script lang="ts" setup>
2
- import { ref, useSlots, watch, toRef, computed, onBeforeUnmount, toRefs, defineExpose } from 'vue'
2
+ /**
3
+ * FzFloating Component
4
+ *
5
+ * A floating container that positions itself relative to an opener element.
6
+ * Supports automatic repositioning on scroll, resize, and content changes.
7
+ *
8
+ * ## Features
9
+ * - 12 positioning options (top/bottom/left/right with start/center/end alignment)
10
+ * - Auto-positioning based on available viewport space
11
+ * - Reactive repositioning via ResizeObserver
12
+ * - Teleport support for z-index stacking context issues
13
+ * - Automatic margin based on resolved position direction
14
+ *
15
+ * ## Slots
16
+ * - `opener`: The element that triggers/anchors the floating content
17
+ * - `default`: The floating content to display
18
+ *
19
+ * ## Example
20
+ * ```vue
21
+ * <FzFloating position="bottom-start" :isOpen="showMenu">
22
+ * <template #opener>
23
+ * <button @click="showMenu = !showMenu">Menu</button>
24
+ * </template>
25
+ * <div>Menu content here</div>
26
+ * </FzFloating>
27
+ * ```
28
+ */
29
+ import { ref, useSlots, watch, toRef, computed, onBeforeUnmount, onMounted, toRefs } from 'vue'
3
30
  import { useFloating } from './composables'
4
31
  import { FzFloatingProps, FzUseFloatingArgs } from './types'
5
32
  import { useMediaQuery } from './composables'
@@ -21,6 +48,11 @@ const slots = useSlots()
21
48
  const xs = useMediaQuery(`(max-width: ${breakpoints.xs})`)
22
49
 
23
50
  let scheduledAnimationFrame = false
51
+ let isMounted = false
52
+
53
+ // Observers for reactive repositioning
54
+ let resizeObserver: ResizeObserver | null = null
55
+ let openerResizeObserver: ResizeObserver | null = null
24
56
 
25
57
  const useFloatingOpts: FzUseFloatingArgs = {
26
58
  position: props.position,
@@ -52,17 +84,72 @@ if (slots.opener) {
52
84
  const floating = useFloating(dynamicOpts)
53
85
 
54
86
  const setPositionWhenOpen = () => {
55
- if (scheduledAnimationFrame) {
87
+ if (scheduledAnimationFrame || !isMounted) {
56
88
  return
57
89
  }
58
90
 
59
91
  scheduledAnimationFrame = true
60
92
  requestAnimationFrame(() => {
61
- props.isOpen && floating.setPosition()
93
+ if (isMounted && props.isOpen) {
94
+ floating.setPosition()
95
+ }
62
96
  scheduledAnimationFrame = false
63
97
  })
64
98
  }
65
99
 
100
+ onMounted(() => {
101
+ isMounted = true
102
+ })
103
+
104
+ // Scroll handler that catches scroll events on any scrollable parent
105
+ const handleScroll = () => {
106
+ setPositionWhenOpen()
107
+ }
108
+
109
+ // Window resize handler
110
+ const handleResize = () => {
111
+ setPositionWhenOpen()
112
+ }
113
+
114
+ // Setup all event listeners and observers
115
+ const setupEventListeners = () => {
116
+ // Window scroll and resize
117
+ window.addEventListener('scroll', handleScroll, true) // capture phase for nested scrollables
118
+ window.addEventListener('resize', handleResize)
119
+
120
+ // ResizeObserver for floating content (handles content size changes)
121
+ if (content.value && !resizeObserver) {
122
+ resizeObserver = new ResizeObserver(() => {
123
+ setPositionWhenOpen()
124
+ })
125
+ resizeObserver.observe(content.value)
126
+ }
127
+
128
+ // ResizeObserver for opener (handles opener size/position changes)
129
+ if (opener.value && !openerResizeObserver) {
130
+ openerResizeObserver = new ResizeObserver(() => {
131
+ setPositionWhenOpen()
132
+ })
133
+ openerResizeObserver.observe(opener.value)
134
+ }
135
+ }
136
+
137
+ // Cleanup all event listeners and observers
138
+ const cleanupEventListeners = () => {
139
+ window.removeEventListener('scroll', handleScroll, true)
140
+ window.removeEventListener('resize', handleResize)
141
+
142
+ if (resizeObserver) {
143
+ resizeObserver.disconnect()
144
+ resizeObserver = null
145
+ }
146
+
147
+ if (openerResizeObserver) {
148
+ openerResizeObserver.disconnect()
149
+ openerResizeObserver = null
150
+ }
151
+ }
152
+
66
153
  watch(
67
154
  () => props.position,
68
155
  () => setPositionWhenOpen()
@@ -70,11 +157,13 @@ watch(
70
157
  watch(
71
158
  () => props.isOpen,
72
159
  (newVal) => {
73
- if (!newVal || !content.value) {
74
- window.removeEventListener('scroll', setPositionWhenOpen)
160
+ if (!newVal || !content.value || !isMounted) {
161
+ cleanupEventListeners()
75
162
  return
76
163
  }
77
- window.addEventListener('scroll', setPositionWhenOpen)
164
+
165
+ setupEventListeners()
166
+
78
167
  const openerRect = opener.value?.getBoundingClientRect()
79
168
  // CRITICAL: Set position fixed immediately to prevent layout shift
80
169
  content.value.style.position = 'fixed'
@@ -98,8 +187,26 @@ watch(
98
187
  }
99
188
  }
100
189
  )
190
+
101
191
  onBeforeUnmount(() => {
102
- window.removeEventListener('scroll', setPositionWhenOpen)
192
+ isMounted = false
193
+ cleanupEventListeners()
194
+ })
195
+
196
+ // Helper function to get margin class from position
197
+ const getMarginClassForPosition = (position: string): string => {
198
+ if (position.startsWith('bottom')) return 'mt-4' // margin-top only
199
+ if (position.startsWith('top')) return 'mb-4' // margin-bottom only
200
+ if (position.startsWith('left')) return 'mr-4' // margin-right only
201
+ if (position.startsWith('right')) return 'ml-4' // margin-left only
202
+ return 'mt-4' // default fallback
203
+ }
204
+
205
+ // Computed margin class based on resolved position
206
+ // Uses actualPosition for auto positions, falls back to props.position for explicit
207
+ const resolvedMarginClass = computed(() => {
208
+ const effectivePosition = floating.actualPosition?.value ?? props.position
209
+ return getMarginClassForPosition(effectivePosition)
103
210
  })
104
211
 
105
212
  const contentClass = computed(() => {
@@ -107,7 +214,7 @@ const contentClass = computed(() => {
107
214
  return props.contentClass
108
215
  }
109
216
 
110
- return ['bg-core-white fixed p-4', props.contentClass]
217
+ return ['bg-core-white fixed', resolvedMarginClass.value, props.contentClass]
111
218
  })
112
219
 
113
220
  defineExpose({
@@ -1,119 +1,13 @@
1
- import { Ref, watch, getCurrentInstance, computed, ref, nextTick } from 'vue'
2
1
  import { FzUseCurrencyOptions } from '../types'
3
- import { format as formatNumber, parse, roundTo } from '../utils'
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
- emitAmount,
117
- setValue
11
+ parse
118
12
  }
119
13
  }
@@ -1,391 +1,580 @@
1
- import { FzFloatingPosition, FzRect, FzUseFloatingArgs } from '../types'
1
+ /**
2
+ * @module useFloating
3
+ * @description Composable for managing floating/popover element positioning
4
+ *
5
+ * ## Why This Refactoring?
6
+ *
7
+ * The previous implementation had several issues:
8
+ *
9
+ * 1. **Monolithic function**: All positioning logic was in a single function
10
+ * making it hard to test individual positioning strategies
11
+ *
12
+ * 2. **Inconsistent margin handling**: Margins were calculated differently for
13
+ * different positions, leading to visual inconsistencies
14
+ *
15
+ * 3. **No reactive repositioning**: The floating element didn't respond to
16
+ * opener/content size changes or scroll events
17
+ *
18
+ * 4. **Layout shift on open**: The floating element was added to document flow
19
+ * before positioning, causing visual jumps
20
+ *
21
+ * ## Architecture
22
+ *
23
+ * The new implementation uses:
24
+ *
25
+ * - **Pure position calculators**: Each position (top, bottom, left, right and
26
+ * their variants) has a dedicated calculator function stored in lookup tables
27
+ *
28
+ * - **Lookup tables**: `positionCalculators` (with opener) and
29
+ * `containerPositionCalculators` (without opener) provide O(1) access
30
+ *
31
+ * - **Immediate fixed positioning**: Elements are set to `position: fixed`
32
+ * immediately to prevent layout shift
33
+ *
34
+ * - **Reactive repositioning**: ResizeObserver and scroll/resize event listeners
35
+ * ensure the floating stays positioned correctly during dynamic changes
36
+ *
37
+ * ## Usage
38
+ *
39
+ * ```typescript
40
+ * const { float, setPosition, actualPosition } = useFloating({
41
+ * element: toRef(props, 'element'),
42
+ * opener: toRef(props, 'opener'),
43
+ * position: toRef(props, 'position'),
44
+ * container: toRef(props, 'container')
45
+ * })
46
+ *
47
+ * // Position is set automatically, or call manually:
48
+ * await setPosition()
49
+ *
50
+ * // Access computed position values:
51
+ * console.log(float.position.x, float.position.y)
52
+ * console.log(actualPosition.value) // Resolved position for 'auto' modes
53
+ * ```
54
+ *
55
+ * ## Position Types
56
+ *
57
+ * - Explicit: top, bottom, left, right and variants (-start, -end)
58
+ * - Auto: auto, auto-vertical, auto-start, auto-end, etc.
59
+ *
60
+ * Auto positions are resolved based on available space around the opener
61
+ *
62
+ * @see FzFloating.vue - The component that uses this composable
63
+ */
64
+
65
+ import { FzFloatingPosition, FzRect, FzUseFloatingArgs, FzAbsolutePosition } from '../types'
2
66
  import { ref, reactive, onUnmounted, Ref, nextTick, ToRefs } from 'vue'
3
67
  import { calcRealPos, getHighestAvailableSpacePos } from '../utils'
4
68
 
5
- export const useFloating = (
6
- args: ToRefs<FzUseFloatingArgs>
7
- ): {
8
- float: FzRect
9
- rect: Ref<DOMRect | undefined>
10
- setPosition: (ignoreCallback?: boolean) => Promise<void>
11
- position: Ref<FzFloatingPosition>
12
- actualPosition?: Ref<FzFloatingPosition | undefined>
13
- openerRect: Ref<DOMRect | undefined>
14
- containerRect: Ref<DOMRect | undefined>
15
- } => {
16
- const safeElementDomRef = ref<HTMLElement | null>(null)
17
- const safeContainerDomRef = ref<HTMLElement | null>(null)
18
- const safeOpenerDomRef = ref<HTMLElement | null>(null)
19
- const openerRect = ref<DOMRect | undefined>()
20
- const containerRect = ref<DOMRect | undefined>()
21
- const position = ref<FzFloatingPosition>('auto')
22
- const rect = ref<DOMRect | undefined>()
23
- const float = reactive<FzRect>({
24
- position: { x: 0, y: 0 }
25
- })
26
- const options: IntersectionObserverInit = {
27
- root: null,
28
- rootMargin: '0px',
29
- threshold: 1.0,
30
- ...args.element.value.intersectionOptions
31
- }
69
+ // ============================================================================
70
+ // Types
71
+ // ============================================================================
32
72
 
33
- const handleIntersect = (
34
- entries: IntersectionObserverEntry[],
35
- observer: IntersectionObserver
36
- ) => {}
73
+ interface DOMRefs {
74
+ element: HTMLElement
75
+ container: HTMLElement
76
+ opener: HTMLElement | null
77
+ }
37
78
 
38
- const floatObserver = ref(new IntersectionObserver(handleIntersect, options))
39
- const actualPosition = ref<FzFloatingPosition>()
79
+ interface Rects {
80
+ element: DOMRect
81
+ container: DOMRect
82
+ opener: DOMRect | null
83
+ }
40
84
 
41
- // Helper: Resolve DOM references
42
- const resolveDomReferences = () => {
43
- safeElementDomRef.value = (
44
- typeof args.element.value.domRef.value === 'string'
45
- ? document.querySelector(args.element.value.domRef.value)
46
- : args.element.value.domRef.value
47
- ) as HTMLElement
48
-
49
- if (!args.container?.value) {
50
- safeContainerDomRef.value = document.body
51
- } else {
52
- safeContainerDomRef.value = (
53
- typeof args.container.value?.domRef.value === 'string'
54
- ? document.querySelector(args.container.value.domRef.value)
55
- : args.container.value?.domRef.value
56
- ) as HTMLElement
57
- safeContainerDomRef.value ??= document.body
58
- }
85
+ interface Margins {
86
+ left: number
87
+ right: number
88
+ top: number
89
+ bottom: number
90
+ }
59
91
 
60
- if (!safeElementDomRef.value) {
61
- throw new Error('missing reference element for floating behavior')
62
- }
92
+ interface Transform {
93
+ x: number
94
+ y: number
95
+ }
63
96
 
64
- if (args.opener?.value) {
65
- safeOpenerDomRef.value = (
66
- typeof args.opener.value?.domRef.value === 'string'
67
- ? document.querySelector(args.opener.value.domRef.value)
68
- : args.opener.value?.domRef.value
69
- ) as HTMLElement
70
- }
97
+ interface PositionResult {
98
+ position: FzAbsolutePosition
99
+ transform: Transform
100
+ }
71
101
 
72
- rect.value = safeElementDomRef.value.getBoundingClientRect()
73
- openerRect.value = safeOpenerDomRef.value?.getBoundingClientRect()
74
- containerRect.value = safeContainerDomRef.value.getBoundingClientRect()
75
- }
102
+ type PositionCalculator = (opener: DOMRect, margins: Margins) => PositionResult
103
+
104
+ // ============================================================================
105
+ // Pure Functions - DOM Reference Resolution
106
+ // ============================================================================
107
+
108
+ const resolveElement = (domRef: string | HTMLElement | null): HTMLElement | null =>
109
+ typeof domRef === 'string' ? document.querySelector(domRef) : domRef
110
+
111
+ const getMargins = (style: CSSStyleDeclaration): Margins => ({
112
+ left: parseFloat(style.marginLeft),
113
+ right: parseFloat(style.marginRight),
114
+ top: parseFloat(style.marginTop),
115
+ bottom: parseFloat(style.marginBottom)
116
+ })
117
+
118
+ // ============================================================================
119
+ // Position Calculators WITH Opener - Lookup Table
120
+ // Each calculator receives opener rect and element margins for consistent spacing
121
+ // ============================================================================
122
+
123
+ const positionCalculators: Record<string, PositionCalculator> = {
124
+ // Bottom positions - content below opener
125
+ 'bottom': (opener, margins) => ({
126
+ position: {
127
+ x: opener.left - margins.left + opener.width / 2,
128
+ y: opener.bottom
129
+ },
130
+ transform: { x: -50, y: 0 }
131
+ }),
132
+
133
+ 'bottom-start': (opener, margins) => ({
134
+ position: {
135
+ x: opener.left - margins.left,
136
+ y: opener.bottom
137
+ },
138
+ transform: { x: 0, y: 0 }
139
+ }),
140
+
141
+ 'bottom-end': (opener, margins) => ({
142
+ position: {
143
+ x: opener.right + margins.right,
144
+ y: opener.bottom
145
+ },
146
+ transform: { x: -100, y: 0 }
147
+ }),
148
+
149
+ // Top positions - content above opener
150
+ 'top': (opener, margins) => ({
151
+ position: {
152
+ x: opener.left - margins.left + opener.width / 2,
153
+ y: opener.top - margins.bottom
154
+ },
155
+ transform: { x: -50, y: -100 }
156
+ }),
157
+
158
+ 'top-start': (opener, margins) => ({
159
+ position: {
160
+ x: opener.left - margins.left,
161
+ y: opener.top - margins.bottom
162
+ },
163
+ transform: { x: 0, y: -100 }
164
+ }),
165
+
166
+ 'top-end': (opener, margins) => ({
167
+ position: {
168
+ x: opener.right + margins.right,
169
+ y: opener.top - margins.bottom
170
+ },
171
+ transform: { x: -100, y: -100 }
172
+ }),
173
+
174
+ // Left positions - content to left of opener
175
+ 'left': (opener, margins) => ({
176
+ position: {
177
+ x: opener.left - margins.right,
178
+ y: opener.top - margins.top + opener.height / 2
179
+ },
180
+ transform: { x: -100, y: -50 }
181
+ }),
182
+
183
+ 'left-start': (opener, margins) => ({
184
+ position: {
185
+ x: opener.left - margins.right,
186
+ y: opener.top - margins.top
187
+ },
188
+ transform: { x: -100, y: 0 }
189
+ }),
190
+
191
+ 'left-end': (opener, margins) => ({
192
+ position: {
193
+ x: opener.left - margins.right,
194
+ y: opener.bottom + margins.bottom
195
+ },
196
+ transform: { x: -100, y: -100 }
197
+ }),
198
+
199
+ // Right positions - content to right of opener
200
+ 'right': (opener, margins) => ({
201
+ position: {
202
+ x: opener.right + margins.left,
203
+ y: opener.top - margins.top + opener.height / 2
204
+ },
205
+ transform: { x: 0, y: -50 }
206
+ }),
207
+
208
+ 'right-start': (opener, margins) => ({
209
+ position: {
210
+ x: opener.right + margins.left,
211
+ y: opener.top - margins.top
212
+ },
213
+ transform: { x: 0, y: 0 }
214
+ }),
215
+
216
+ 'right-end': (opener, margins) => ({
217
+ position: {
218
+ x: opener.right + margins.left,
219
+ y: opener.bottom + margins.bottom
220
+ },
221
+ transform: { x: 0, y: -100 }
222
+ })
223
+ }
76
224
 
77
- // Helper: Resolve auto position to actual position
78
- const resolveAutoPositionValue = () => {
79
- if (!args.opener?.value || !safeOpenerDomRef.value) return
225
+ const calculatePositionWithOpener = (
226
+ position: FzFloatingPosition,
227
+ opener: DOMRect,
228
+ margins: Margins
229
+ ): PositionResult => {
230
+ const calculator = positionCalculators[position]
231
+ return calculator
232
+ ? calculator(opener, margins)
233
+ : { position: { x: 0, y: 0 }, transform: { x: 0, y: 0 } }
234
+ }
80
235
 
81
- switch (actualPosition.value) {
82
- case 'auto':
83
- actualPosition.value = getHighestAvailableSpacePos(
84
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
85
- safeElementDomRef.value as HTMLElement,
86
- safeOpenerDomRef.value as HTMLElement
87
- )
88
- break
89
- case 'auto-vertical':
90
- actualPosition.value = getHighestAvailableSpacePos(
91
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
92
- safeElementDomRef.value as HTMLElement,
93
- safeOpenerDomRef.value as HTMLElement,
94
- undefined,
95
- true
96
- )
97
- break
98
- case 'auto-start':
99
- actualPosition.value = getHighestAvailableSpacePos(
100
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
101
- safeElementDomRef.value as HTMLElement,
102
- safeOpenerDomRef.value as HTMLElement,
103
- 'start'
104
- )
105
- break
106
- case 'auto-vertical-start':
107
- actualPosition.value = getHighestAvailableSpacePos(
108
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
109
- safeElementDomRef.value as HTMLElement,
110
- safeOpenerDomRef.value as HTMLElement,
111
- 'start',
112
- true
113
- )
114
- break
115
- case 'auto-end':
116
- actualPosition.value = getHighestAvailableSpacePos(
117
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
118
- safeElementDomRef.value as HTMLElement,
119
- safeOpenerDomRef.value as HTMLElement,
120
- 'end'
121
- )
122
- break
123
- case 'auto-vertical-end':
124
- actualPosition.value = getHighestAvailableSpacePos(
125
- args.useViewport?.value ? null : (safeContainerDomRef.value as HTMLElement),
126
- safeElementDomRef.value as HTMLElement,
127
- safeOpenerDomRef.value as HTMLElement,
128
- 'end',
129
- true
130
- )
131
- break
132
- default:
133
- break
134
- }
236
+ // ============================================================================
237
+ // Position Calculators WITHOUT Opener - Lookup Table
238
+ // ============================================================================
239
+
240
+ type ContainerPositionCalculator = (container: DOMRect, element: DOMRect) => PositionResult
241
+
242
+ const containerPositionCalculators: Record<string, ContainerPositionCalculator> = {
243
+ 'bottom': (container, element) => ({
244
+ position: {
245
+ x: container.left + container.width / 2,
246
+ y: container.bottom - element.height
247
+ },
248
+ transform: { x: -50, y: 0 }
249
+ }),
250
+
251
+ 'bottom-start': (container, element) => ({
252
+ position: {
253
+ x: container.left,
254
+ y: container.bottom - element.height
255
+ },
256
+ transform: { x: 0, y: 0 }
257
+ }),
258
+
259
+ 'bottom-end': (container, element) => ({
260
+ position: {
261
+ x: container.right - element.width,
262
+ y: container.bottom - element.height
263
+ },
264
+ transform: { x: 0, y: 0 }
265
+ }),
266
+
267
+ 'top': (container) => ({
268
+ position: {
269
+ x: container.left + container.width / 2,
270
+ y: container.top
271
+ },
272
+ transform: { x: -50, y: 0 }
273
+ }),
274
+
275
+ 'top-start': (container) => ({
276
+ position: {
277
+ x: container.left,
278
+ y: container.top
279
+ },
280
+ transform: { x: 0, y: 0 }
281
+ }),
282
+
283
+ 'top-end': (container, element) => ({
284
+ position: {
285
+ x: container.right - element.width,
286
+ y: container.top
287
+ },
288
+ transform: { x: 0, y: 0 }
289
+ }),
290
+
291
+ 'left': (container, element) => ({
292
+ position: {
293
+ x: container.left,
294
+ y: container.top + (container.height - element.height) / 2
295
+ },
296
+ transform: { x: 0, y: 0 }
297
+ }),
298
+
299
+ 'left-start': (container) => ({
300
+ position: {
301
+ x: container.left,
302
+ y: container.top
303
+ },
304
+ transform: { x: 0, y: 0 }
305
+ }),
306
+
307
+ 'left-end': (container, element) => ({
308
+ position: {
309
+ x: container.left,
310
+ y: container.bottom - element.height
311
+ },
312
+ transform: { x: 0, y: 0 }
313
+ }),
314
+
315
+ 'right': (container, element) => ({
316
+ position: {
317
+ x: container.right - element.width,
318
+ y: container.top + (container.height - element.height) / 2
319
+ },
320
+ transform: { x: 0, y: 0 }
321
+ }),
322
+
323
+ 'right-start': (container, element) => ({
324
+ position: {
325
+ x: container.right - element.width,
326
+ y: container.top
327
+ },
328
+ transform: { x: 0, y: 0 }
329
+ }),
330
+
331
+ 'right-end': (container, element) => ({
332
+ position: {
333
+ x: container.right - element.width,
334
+ y: container.bottom - element.height
335
+ },
336
+ transform: { x: 0, y: 0 }
337
+ })
338
+ }
339
+
340
+ const calculatePositionWithoutOpener = (
341
+ position: FzFloatingPosition,
342
+ container: DOMRect,
343
+ element: DOMRect
344
+ ): PositionResult => {
345
+ const calculator = containerPositionCalculators[position]
346
+ return calculator
347
+ ? calculator(container, element)
348
+ : { position: { x: 0, y: 0 }, transform: { x: 0, y: 0 } }
349
+ }
350
+
351
+ // ============================================================================
352
+ // Boundary Correction - Pure Function
353
+ // ============================================================================
354
+
355
+ const applyBoundaryCorrections = (
356
+ realPosition: FzAbsolutePosition,
357
+ element: DOMRect,
358
+ container: DOMRect,
359
+ transform: Transform
360
+ ): { position: FzAbsolutePosition; transform: Transform } => {
361
+ const correctedPosition = { ...realPosition }
362
+ const correctedTransform = { ...transform }
363
+
364
+ // Left boundary
365
+ if (realPosition.x < container.left) {
366
+ correctedPosition.x = container.left
367
+ correctedTransform.x = 0
135
368
  }
136
369
 
137
- // Helper: Calculate position with opener
138
- const calculatePositionWithOpener = (
139
- elStyle: CSSStyleDeclaration,
140
- translateX: { value: number },
141
- translateY: { value: number }
142
- ) => {
143
- if (!openerRect.value) return
144
-
145
- const leftWithoutXMargin =
146
- openerRect.value.left - parseFloat(elStyle.marginLeft) - parseFloat(elStyle.marginRight)
147
- const leftWithoutLeftMargin = openerRect.value.left - parseFloat(elStyle.marginLeft)
148
- const topWithoutYMargin =
149
- openerRect.value.top - parseFloat(elStyle.marginTop) - parseFloat(elStyle.marginBottom)
150
- const topWithoutTopMargin = openerRect.value.top - parseFloat(elStyle.marginTop)
151
-
152
- switch (actualPosition.value) {
153
- case 'bottom':
154
- float.position.y = openerRect.value.bottom
155
- float.position.x = leftWithoutLeftMargin + openerRect.value.width / 2
156
- translateX.value = -50
157
- translateY.value = 0
158
- break
159
- case 'bottom-start':
160
- float.position.y = openerRect.value.bottom
161
- float.position.x = leftWithoutXMargin
162
- translateX.value = 0
163
- translateY.value = 0
164
- break
165
- case 'bottom-end':
166
- float.position.y = openerRect.value.bottom
167
- float.position.x = openerRect.value.right
168
- translateX.value = -100
169
- translateY.value = 0
170
- break
171
- case 'left-start':
172
- float.position.y = topWithoutYMargin
173
- float.position.x = leftWithoutXMargin
174
- translateX.value = -100
175
- translateY.value = 0
176
- break
177
- case 'left':
178
- float.position.y = topWithoutTopMargin + openerRect.value.height / 2
179
- float.position.x = leftWithoutXMargin
180
- translateY.value = -50
181
- translateX.value = -100
182
- break
183
- case 'left-end':
184
- float.position.y = openerRect.value.bottom
185
- float.position.x = leftWithoutXMargin
186
- translateY.value = -100
187
- translateX.value = -100
188
- break
189
- case 'top-start':
190
- float.position.y = topWithoutYMargin
191
- float.position.x = leftWithoutXMargin
192
- translateY.value = -100
193
- translateX.value = 0
194
- break
195
- case 'top':
196
- float.position.y = topWithoutYMargin
197
- float.position.x = leftWithoutLeftMargin + openerRect.value.width / 2
198
- translateX.value = -50
199
- translateY.value = -100
200
- break
201
- case 'top-end':
202
- float.position.y = topWithoutYMargin
203
- float.position.x = openerRect.value.right
204
- translateX.value = -100
205
- translateY.value = -100
206
- break
207
- case 'right-start':
208
- float.position.y = topWithoutYMargin
209
- float.position.x = openerRect.value.right
210
- translateX.value = 0
211
- translateY.value = 0
212
- break
213
- case 'right':
214
- float.position.y = topWithoutTopMargin + openerRect.value.height / 2
215
- float.position.x = openerRect.value.right
216
- translateY.value = -50
217
- translateX.value = 0
218
- break
219
- case 'right-end':
220
- float.position.y = openerRect.value.bottom
221
- float.position.x = openerRect.value.right
222
- translateY.value = -100
223
- translateX.value = 0
224
- break
225
- default:
226
- break
370
+ // Right boundary
371
+ if (realPosition.x + element.width > container.right) {
372
+ const fixedX = container.right - element.width
373
+ if (fixedX > 0) {
374
+ correctedPosition.x = fixedX
227
375
  }
376
+ correctedTransform.x = 0
228
377
  }
229
378
 
230
- // Helper: Calculate position without opener
231
- const calculatePositionWithoutOpener = (translateX: { value: number }) => {
232
- if (!containerRect.value || !rect.value) return
233
-
234
- switch (actualPosition.value) {
235
- case 'bottom':
236
- float.position.y = containerRect.value.bottom - rect.value.height
237
- float.position.x = containerRect.value.left + containerRect.value.width / 2
238
- translateX.value = -50
239
- break
240
- case 'left-end':
241
- case 'bottom-start':
242
- float.position.y = containerRect.value.bottom - rect.value.height
243
- float.position.x = containerRect.value.left
244
- break
245
- case 'right-end':
246
- case 'bottom-end':
247
- float.position.y = containerRect.value.bottom - rect.value.height
248
- float.position.x = containerRect.value.right - rect.value.width
249
- break
250
- case 'left':
251
- float.position.y =
252
- containerRect.value.top + (containerRect.value.height - rect.value.height) / 2
253
- float.position.x = containerRect.value.left
254
- break
255
- case 'top-start':
256
- case 'left-start':
257
- float.position.y = containerRect.value.top
258
- float.position.x = containerRect.value.left
259
- break
260
- case 'top':
261
- float.position.y = containerRect.value.top
262
- float.position.x =
263
- containerRect.value.left + (containerRect.value.width - rect.value.width) / 2
264
- break
265
- case 'top-end':
266
- case 'right-start':
267
- float.position.y = containerRect.value.top
268
- float.position.x = containerRect.value.right - rect.value.width
269
- break
270
- case 'right':
271
- float.position.y =
272
- containerRect.value.top + (containerRect.value.height - rect.value.height) / 2
273
- float.position.x = containerRect.value.right - rect.value.width
274
- break
275
- default:
276
- break
277
- }
379
+ // Top boundary
380
+ if (realPosition.y < container.top) {
381
+ correctedPosition.y = container.top
382
+ correctedTransform.y = 0
278
383
  }
279
384
 
280
- // Helper: Apply boundary corrections
281
- const applyBoundaryCorrections = (
282
- realPos: { x: number; y: number },
283
- translateX: { value: number },
284
- translateY: { value: number }
285
- ) => {
286
- if (!containerRect.value || !rect.value) return
287
-
288
- if (realPos.x < containerRect.value.left) {
289
- float.position.x = containerRect.value.left
290
- translateX.value = 0
385
+ // Bottom boundary
386
+ if (realPosition.y + element.height > container.bottom) {
387
+ const fixedY = container.bottom - element.height
388
+ if (fixedY > 0) {
389
+ correctedPosition.y = fixedY
291
390
  }
391
+ correctedTransform.y = 0
392
+ }
292
393
 
293
- if (realPos.x + rect.value.width > containerRect.value.right) {
294
- const fixVal = containerRect.value.right - rect.value.width
295
- if (fixVal > 0) {
296
- float.position.x = fixVal
297
- }
298
- translateX.value = 0
299
- }
394
+ return { position: correctedPosition, transform: correctedTransform }
395
+ }
300
396
 
301
- if (realPos.y < containerRect.value.top) {
302
- float.position.y = containerRect.value.top
303
- translateY.value = 0
304
- }
397
+ // ============================================================================
398
+ // Auto Position Resolution - Pure Function
399
+ // ============================================================================
305
400
 
306
- if (realPos.y + rect.value.height > containerRect.value.bottom) {
307
- const fixVal = containerRect.value.bottom - rect.value.height
308
- if (fixVal > 0) {
309
- float.position.y = fixVal
310
- }
311
- translateY.value = 0
312
- }
313
- }
401
+ type AutoPositionType = 'auto' | 'auto-vertical' | 'auto-start' | 'auto-vertical-start' | 'auto-end' | 'auto-vertical-end'
314
402
 
315
- // Helper: Apply element styles
316
- const applyElementStyles = () => {
317
- if (!safeElementDomRef.value) return
403
+ const resolveAutoPosition = (
404
+ autoType: AutoPositionType,
405
+ container: HTMLElement | null,
406
+ element: HTMLElement,
407
+ opener: HTMLElement,
408
+ useViewport: boolean
409
+ ): FzFloatingPosition => {
410
+ const containerEl = useViewport ? null : container
318
411
 
319
- safeElementDomRef.value.style.top = `${float.position.y}px`
320
- safeElementDomRef.value.style.left = `${float.position.x}px`
321
- safeElementDomRef.value.style.position = 'fixed'
322
- safeElementDomRef.value.style.display = 'flex'
323
- }
412
+ switch (autoType) {
413
+ case 'auto':
414
+ return getHighestAvailableSpacePos(containerEl, element, opener)
324
415
 
325
- const setPosition = (ignoreCallback: boolean = false) =>
326
- nextTick(() => {
327
- actualPosition.value = args.position ? args.position.value : 'auto'
416
+ case 'auto-vertical':
417
+ return getHighestAvailableSpacePos(containerEl, element, opener, undefined, true)
328
418
 
329
- // Step 1: Resolve DOM references
330
- resolveDomReferences()
419
+ case 'auto-start':
420
+ return getHighestAvailableSpacePos(containerEl, element, opener, 'start')
331
421
 
332
- // Step 1.5: CRITICAL - Remove element from document flow immediately
333
- // This prevents the floating element from pushing the opener and causing layout shift
334
- if (safeElementDomRef.value) {
335
- safeElementDomRef.value.style.position = 'fixed'
336
- safeElementDomRef.value.style.top = '0px'
337
- safeElementDomRef.value.style.left = '0px'
338
- }
422
+ case 'auto-vertical-start':
423
+ return getHighestAvailableSpacePos(containerEl, element, opener, 'start', true)
339
424
 
340
- // Recalculate all rects after content is rendered
341
- rect.value = safeElementDomRef.value!.getBoundingClientRect()
342
- openerRect.value = safeOpenerDomRef.value?.getBoundingClientRect()
343
- containerRect.value = safeContainerDomRef.value!.getBoundingClientRect()
425
+ case 'auto-end':
426
+ return getHighestAvailableSpacePos(containerEl, element, opener, 'end')
344
427
 
345
- const elStyle = window.getComputedStyle(safeElementDomRef.value as HTMLElement)
428
+ case 'auto-vertical-end':
429
+ return getHighestAvailableSpacePos(containerEl, element, opener, 'end', true)
346
430
 
347
- // Step 2: Setup intersection observer
348
- floatObserver.value.observe(safeElementDomRef.value as HTMLElement)
349
- floatObserver.value.observe(safeContainerDomRef.value as HTMLElement)
431
+ default:
432
+ return 'bottom-start'
433
+ }
434
+ }
350
435
 
351
- // Step 3: Resolve auto positions
352
- resolveAutoPositionValue()
436
+ const isAutoPosition = (position: FzFloatingPosition): position is AutoPositionType =>
437
+ position.startsWith('auto')
353
438
 
354
- // Step 4: Calculate position based on opener presence
355
- const translate = { x: { value: 0 }, y: { value: 0 } }
439
+ // ============================================================================
440
+ // Main Composable
441
+ // ============================================================================
356
442
 
357
- if (args.opener?.value && safeOpenerDomRef.value && openerRect?.value) {
358
- calculatePositionWithOpener(elStyle, translate.x, translate.y)
359
- } else {
360
- calculatePositionWithoutOpener(translate.x)
361
- }
443
+ export const useFloating = (
444
+ args: ToRefs<FzUseFloatingArgs>
445
+ ): {
446
+ float: FzRect
447
+ rect: Ref<DOMRect | undefined>
448
+ setPosition: (ignoreCallback?: boolean) => Promise<void>
449
+ position: Ref<FzFloatingPosition>
450
+ actualPosition?: Ref<FzFloatingPosition | undefined>
451
+ openerRect: Ref<DOMRect | undefined>
452
+ containerRect: Ref<DOMRect | undefined>
453
+ } => {
454
+ // State
455
+ const position = ref<FzFloatingPosition>('auto')
456
+ const actualPosition = ref<FzFloatingPosition>()
457
+ const rect = ref<DOMRect>()
458
+ const openerRect = ref<DOMRect>()
459
+ const containerRect = ref<DOMRect>()
460
+ const float = reactive<FzRect>({ position: { x: 0, y: 0 } })
362
461
 
363
- // Step 5: Calculate real position and apply boundary corrections
364
- if (rect.value) {
365
- const realPos = calcRealPos(
366
- rect.value,
367
- float.position,
368
- translate.x.value,
369
- translate.y.value
370
- )
371
- float.position.x = realPos.x
372
- float.position.y = realPos.y
462
+ // Intersection Observer
463
+ const observerOptions: IntersectionObserverInit = {
464
+ root: null,
465
+ rootMargin: '0px',
466
+ threshold: 1.0,
467
+ ...args.element.value.intersectionOptions
468
+ }
469
+ const floatObserver = ref(new IntersectionObserver(() => {}, observerOptions))
373
470
 
374
- applyBoundaryCorrections(realPos, translate.x, translate.y)
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
- // Step 6: Apply styles to element
378
- applyElementStyles()
482
+ const opener = args.opener?.value
483
+ ? resolveElement(args.opener.value.domRef.value)
484
+ : null
379
485
 
380
- // Step 7: Update rect after positioning
381
- rect.value = safeElementDomRef.value!.getBoundingClientRect()
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 8: Trigger callback if provided
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
- import { FzUseCurrencyOptions } from '../../types'
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
- export const format = (options: FzUseCurrencyOptions) => (input: number | null) => {
4
- if (input === null) {
5
- return ''
6
- }
7
- const safeOptions: FzUseCurrencyOptions = {
8
- maximumFractionDigits: options.maximumFractionDigits || 2,
9
- minimumFractionDigits: options.minimumFractionDigits,
10
- useGrouping: options.useGrouping || false
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
- return input.toLocaleString('it-IT', safeOptions)
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
- // strip currency, handle edge cases...
18
- return parseFloat(text.replace(/,/g, '.'))
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
  }