base-ui-vue 0.2.0 → 0.4.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.
Files changed (109) hide show
  1. package/dist/button/ToolbarButton.cjs +6 -0
  2. package/dist/button/ToolbarButton.js +1 -1
  3. package/dist/content/ScrollAreaContent.cjs +168 -0
  4. package/dist/content/ScrollAreaContent.cjs.map +1 -0
  5. package/dist/content/ScrollAreaContent.js +133 -0
  6. package/dist/content/ScrollAreaContent.js.map +1 -0
  7. package/dist/control/SliderControl.js +2 -2
  8. package/dist/corner/ScrollAreaCorner.cjs +77 -0
  9. package/dist/corner/ScrollAreaCorner.cjs.map +1 -0
  10. package/dist/corner/ScrollAreaCorner.js +72 -0
  11. package/dist/corner/ScrollAreaCorner.js.map +1 -0
  12. package/dist/decrement/NumberFieldDecrement.cjs +861 -0
  13. package/dist/decrement/NumberFieldDecrement.cjs.map +1 -0
  14. package/dist/decrement/NumberFieldDecrement.js +700 -0
  15. package/dist/decrement/NumberFieldDecrement.js.map +1 -0
  16. package/dist/fallback/AvatarFallback.cjs +2 -46
  17. package/dist/fallback/AvatarFallback.cjs.map +1 -1
  18. package/dist/fallback/AvatarFallback.js +3 -41
  19. package/dist/fallback/AvatarFallback.js.map +1 -1
  20. package/dist/group/NumberFieldGroup.cjs +72 -0
  21. package/dist/group/NumberFieldGroup.cjs.map +1 -0
  22. package/dist/group/NumberFieldGroup.js +67 -0
  23. package/dist/group/NumberFieldGroup.js.map +1 -0
  24. package/dist/increment/NumberFieldIncrement.cjs +112 -0
  25. package/dist/increment/NumberFieldIncrement.cjs.map +1 -0
  26. package/dist/increment/NumberFieldIncrement.js +107 -0
  27. package/dist/increment/NumberFieldIncrement.js.map +1 -0
  28. package/dist/index.cjs +52 -0
  29. package/dist/index.d.cts +1761 -430
  30. package/dist/index.d.cts.map +1 -1
  31. package/dist/index.d.ts +1761 -430
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -2
  34. package/dist/index2.cjs +4065 -60
  35. package/dist/index2.cjs.map +1 -1
  36. package/dist/index2.js +3955 -184
  37. package/dist/index2.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/index.ts +6 -0
  40. package/src/input/Input.vue +37 -0
  41. package/src/input/InputDataAttributes.ts +30 -0
  42. package/src/input/index.ts +4 -0
  43. package/src/meter/index.ts +16 -0
  44. package/src/meter/indicator/MeterIndicator.vue +65 -0
  45. package/src/meter/label/MeterLabel.vue +63 -0
  46. package/src/meter/root/MeterRoot.vue +131 -0
  47. package/src/meter/root/MeterRootContext.ts +41 -0
  48. package/src/meter/track/MeterTrack.vue +46 -0
  49. package/src/meter/value/MeterValue.vue +85 -0
  50. package/src/number-field/decrement/NumberFieldDecrement.vue +109 -0
  51. package/src/number-field/group/NumberFieldGroup.vue +47 -0
  52. package/src/number-field/increment/NumberFieldIncrement.vue +109 -0
  53. package/src/number-field/index.ts +42 -0
  54. package/src/number-field/input/NumberFieldInput.vue +455 -0
  55. package/src/number-field/root/NumberFieldRoot.vue +626 -0
  56. package/src/number-field/root/NumberFieldRootContext.ts +94 -0
  57. package/src/number-field/root/useNumberFieldButton.ts +171 -0
  58. package/src/number-field/scrub-area/NumberFieldScrubArea.vue +359 -0
  59. package/src/number-field/scrub-area/NumberFieldScrubAreaContext.ts +26 -0
  60. package/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.vue +75 -0
  61. package/src/number-field/utils/constants.ts +4 -0
  62. package/src/number-field/utils/getViewportRect.ts +34 -0
  63. package/src/number-field/utils/parse.ts +248 -0
  64. package/src/number-field/utils/stateAttributesMapping.ts +9 -0
  65. package/src/number-field/utils/subscribeToVisualViewportResize.ts +27 -0
  66. package/src/number-field/utils/types.ts +24 -0
  67. package/src/number-field/utils/validate.ts +120 -0
  68. package/src/otp-field/index.ts +22 -0
  69. package/src/otp-field/input/OtpFieldInput.vue +336 -0
  70. package/src/otp-field/root/OtpFieldRoot.vue +583 -0
  71. package/src/otp-field/root/OtpFieldRootContext.ts +81 -0
  72. package/src/otp-field/utils/otp.ts +135 -0
  73. package/src/otp-field/utils/stateAttributesMapping.ts +16 -0
  74. package/src/progress/index.ts +23 -0
  75. package/src/progress/indicator/ProgressIndicator.vue +74 -0
  76. package/src/progress/label/ProgressLabel.vue +63 -0
  77. package/src/progress/root/ProgressRoot.vue +160 -0
  78. package/src/progress/root/ProgressRootContext.ts +51 -0
  79. package/src/progress/root/ProgressRootDataAttributes.ts +14 -0
  80. package/src/progress/root/stateAttributesMapping.ts +18 -0
  81. package/src/progress/track/ProgressTrack.vue +48 -0
  82. package/src/progress/value/ProgressValue.vue +92 -0
  83. package/src/scroll-area/constants.ts +2 -0
  84. package/src/scroll-area/content/ScrollAreaContent.vue +87 -0
  85. package/src/scroll-area/corner/ScrollAreaCorner.vue +64 -0
  86. package/src/scroll-area/index.ts +25 -0
  87. package/src/scroll-area/root/ScrollAreaRoot.vue +297 -0
  88. package/src/scroll-area/root/ScrollAreaRootContext.ts +89 -0
  89. package/src/scroll-area/root/ScrollAreaRootCssVars.ts +4 -0
  90. package/src/scroll-area/root/ScrollAreaRootDataAttributes.ts +9 -0
  91. package/src/scroll-area/root/stateAttributes.ts +14 -0
  92. package/src/scroll-area/scrollbar/ScrollAreaScrollbar.vue +263 -0
  93. package/src/scroll-area/scrollbar/ScrollAreaScrollbarContext.ts +20 -0
  94. package/src/scroll-area/scrollbar/ScrollAreaScrollbarCssVars.ts +4 -0
  95. package/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +11 -0
  96. package/src/scroll-area/thumb/ScrollAreaThumb.vue +120 -0
  97. package/src/scroll-area/thumb/ScrollAreaThumbDataAttributes.ts +3 -0
  98. package/src/scroll-area/utils/getOffset.ts +34 -0
  99. package/src/scroll-area/viewport/ScrollAreaViewport.vue +379 -0
  100. package/src/scroll-area/viewport/ScrollAreaViewportContext.ts +20 -0
  101. package/src/scroll-area/viewport/ScrollAreaViewportCssVars.ts +6 -0
  102. package/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts +9 -0
  103. package/src/utils/detectBrowser.ts +15 -0
  104. package/src/utils/formatNumber.ts +60 -2
  105. package/src/utils/scrollEdges.ts +33 -0
  106. package/src/utils/styles.ts +28 -0
  107. package/src/utils/useInterval.ts +45 -0
  108. package/src/utils/usePressAndHold.ts +260 -0
  109. package/src/utils/useValueChanged.ts +21 -0
@@ -0,0 +1,379 @@
1
+ <script setup lang="ts">
2
+ import type { BaseUIComponentProps } from '../../utils/types'
3
+ import type { HiddenState, ScrollAreaRootState } from '../root/ScrollAreaRootContext'
4
+ import { computed, onBeforeUnmount, onMounted, provide, useAttrs, watch } from 'vue'
5
+ import { useDirection } from '../../direction-provider/DirectionContext'
6
+ import { mergeProps } from '../../merge-props/mergeProps'
7
+ import { clamp } from '../../utils/clamp'
8
+ import { normalizeScrollOffset } from '../../utils/scrollEdges'
9
+ import { styleDisableScrollbar } from '../../utils/styles'
10
+ import { useRenderElement } from '../../utils/useRenderElement'
11
+ import { MIN_THUMB_SIZE } from '../constants'
12
+ import { useScrollAreaRootContext } from '../root/ScrollAreaRootContext'
13
+ import { scrollAreaStateAttributesMapping } from '../root/stateAttributes'
14
+ import { getOffset } from '../utils/getOffset'
15
+ import { scrollAreaViewportContextKey } from './ScrollAreaViewportContext'
16
+ import { ScrollAreaViewportCssVars } from './ScrollAreaViewportCssVars'
17
+
18
+ export type ScrollAreaViewportState = ScrollAreaRootState
19
+
20
+ export interface ScrollAreaViewportProps extends BaseUIComponentProps<ScrollAreaViewportState> {}
21
+
22
+ defineOptions({
23
+ name: 'ScrollAreaViewport',
24
+ inheritAttrs: false,
25
+ })
26
+
27
+ const props = withDefaults(defineProps<ScrollAreaViewportProps>(), {
28
+ as: 'div',
29
+ })
30
+
31
+ const attrs = useAttrs()
32
+
33
+ const {
34
+ viewportRef,
35
+ scrollbarYRef,
36
+ scrollbarXRef,
37
+ thumbYRef,
38
+ thumbXRef,
39
+ cornerRef,
40
+ cornerSize,
41
+ setCornerSize,
42
+ setThumbSize,
43
+ rootId,
44
+ setHiddenState,
45
+ hiddenState,
46
+ setHasMeasuredScrollbar,
47
+ handleScroll,
48
+ setHovering,
49
+ setOverflowEdges,
50
+ overflowEdges,
51
+ overflowEdgeThreshold,
52
+ scrollingX,
53
+ scrollingY,
54
+ } = useScrollAreaRootContext()
55
+
56
+ const direction = useDirection()
57
+
58
+ let programmaticScroll = true
59
+ const lastMeasuredViewportMetrics: [number, number, number, number] = [Number.NaN, Number.NaN, Number.NaN, Number.NaN]
60
+
61
+ let scrollEndTimeoutId: ReturnType<typeof setTimeout> | undefined
62
+ let waitForAnimationsTimeoutId: ReturnType<typeof setTimeout> | undefined
63
+ let resizeObserver: ResizeObserver | undefined
64
+
65
+ let scrollAreaOverflowVarsRegistered = false
66
+
67
+ function removeCSSVariableInheritance() {
68
+ if (scrollAreaOverflowVarsRegistered)
69
+ return
70
+
71
+ const isWebKit = typeof navigator !== 'undefined' && /AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)
72
+ if (isWebKit)
73
+ return
74
+
75
+ if (typeof CSS !== 'undefined' && 'registerProperty' in CSS) {
76
+ const vars = [
77
+ ScrollAreaViewportCssVars.scrollAreaOverflowXStart,
78
+ ScrollAreaViewportCssVars.scrollAreaOverflowXEnd,
79
+ ScrollAreaViewportCssVars.scrollAreaOverflowYStart,
80
+ ScrollAreaViewportCssVars.scrollAreaOverflowYEnd,
81
+ ]
82
+ for (const name of vars) {
83
+ try {
84
+ CSS.registerProperty({ name, syntax: '<length>', inherits: false, initialValue: '0px' })
85
+ }
86
+ catch { /* already registered */ }
87
+ }
88
+ }
89
+
90
+ scrollAreaOverflowVarsRegistered = true
91
+ }
92
+
93
+ function computeThumbPosition() {
94
+ const viewportEl = viewportRef.value
95
+ const scrollbarYEl = scrollbarYRef.value
96
+ const scrollbarXEl = scrollbarXRef.value
97
+ const thumbYEl = thumbYRef.value
98
+ const thumbXEl = thumbXRef.value
99
+ const cornerEl = cornerRef.value
100
+
101
+ if (!viewportEl)
102
+ return
103
+
104
+ const scrollableContentHeight = viewportEl.scrollHeight
105
+ const scrollableContentWidth = viewportEl.scrollWidth
106
+ const viewportHeight = viewportEl.clientHeight
107
+ const viewportWidth = viewportEl.clientWidth
108
+ const scrollTop = viewportEl.scrollTop
109
+ const scrollLeft = viewportEl.scrollLeft
110
+ const isFirstMeasurement = Number.isNaN(lastMeasuredViewportMetrics[0])
111
+ lastMeasuredViewportMetrics[0] = viewportHeight
112
+ lastMeasuredViewportMetrics[1] = scrollableContentHeight
113
+ lastMeasuredViewportMetrics[2] = viewportWidth
114
+ lastMeasuredViewportMetrics[3] = scrollableContentWidth
115
+
116
+ if (isFirstMeasurement) {
117
+ setHasMeasuredScrollbar(true)
118
+ }
119
+
120
+ if (scrollableContentHeight === 0 || scrollableContentWidth === 0)
121
+ return
122
+
123
+ const nextHiddenState = getHiddenState(viewportEl)
124
+ const scrollbarYHidden = nextHiddenState.y
125
+ const scrollbarXHidden = nextHiddenState.x
126
+ const ratioX = viewportWidth / scrollableContentWidth
127
+ const ratioY = viewportHeight / scrollableContentHeight
128
+ const maxScrollLeft = Math.max(0, scrollableContentWidth - viewportWidth)
129
+ const maxScrollTop = Math.max(0, scrollableContentHeight - viewportHeight)
130
+
131
+ let scrollLeftFromStart = 0
132
+ let scrollLeftFromEnd = 0
133
+ if (!scrollbarXHidden) {
134
+ let rawScrollLeftFromStart = 0
135
+ if (direction.value === 'rtl') {
136
+ rawScrollLeftFromStart = scrollLeft < 0 ? -scrollLeft : maxScrollLeft - scrollLeft
137
+ }
138
+ else {
139
+ rawScrollLeftFromStart = scrollLeft
140
+ }
141
+ rawScrollLeftFromStart = clamp(rawScrollLeftFromStart, 0, maxScrollLeft)
142
+ scrollLeftFromStart = normalizeScrollOffset(rawScrollLeftFromStart, maxScrollLeft)
143
+ scrollLeftFromEnd = maxScrollLeft - scrollLeftFromStart
144
+ }
145
+
146
+ const rawScrollTopFromStart = !scrollbarYHidden ? clamp(scrollTop, 0, maxScrollTop) : 0
147
+ const scrollTopFromStart = !scrollbarYHidden ? normalizeScrollOffset(rawScrollTopFromStart, maxScrollTop) : 0
148
+ const scrollTopFromEnd = !scrollbarYHidden ? maxScrollTop - scrollTopFromStart : 0
149
+ const nextWidth = scrollbarXHidden ? 0 : viewportWidth
150
+ const nextHeight = scrollbarYHidden ? 0 : viewportHeight
151
+
152
+ let nextCornerWidth = 0
153
+ let nextCornerHeight = 0
154
+ if (!scrollbarXHidden && !scrollbarYHidden) {
155
+ nextCornerWidth = scrollbarYEl?.offsetWidth || 0
156
+ nextCornerHeight = scrollbarXEl?.offsetHeight || 0
157
+ }
158
+
159
+ const cornerNotYetSized = cornerSize.value.width === 0 && cornerSize.value.height === 0
160
+ const cornerWidthOffset = cornerNotYetSized ? nextCornerWidth : 0
161
+ const cornerHeightOffset = cornerNotYetSized ? nextCornerHeight : 0
162
+
163
+ const scrollbarXOffset = getOffset(scrollbarXEl, 'padding', 'x')
164
+ const scrollbarYOffset = getOffset(scrollbarYEl, 'padding', 'y')
165
+ const thumbXOffset = getOffset(thumbXEl, 'margin', 'x')
166
+ const thumbYOffset = getOffset(thumbYEl, 'margin', 'y')
167
+
168
+ const idealNextWidth = nextWidth - scrollbarXOffset - thumbXOffset
169
+ const idealNextHeight = nextHeight - scrollbarYOffset - thumbYOffset
170
+
171
+ const maxNextWidth = scrollbarXEl ? Math.min(scrollbarXEl.offsetWidth - cornerWidthOffset, idealNextWidth) : idealNextWidth
172
+ const maxNextHeight = scrollbarYEl ? Math.min(scrollbarYEl.offsetHeight - cornerHeightOffset, idealNextHeight) : idealNextHeight
173
+
174
+ const clampedNextWidth = Math.max(MIN_THUMB_SIZE, maxNextWidth * ratioX)
175
+ const clampedNextHeight = Math.max(MIN_THUMB_SIZE, maxNextHeight * ratioY)
176
+
177
+ setThumbSize({ width: clampedNextWidth, height: clampedNextHeight })
178
+
179
+ if (scrollbarYEl && thumbYEl) {
180
+ const maxThumbOffsetY = scrollbarYEl.offsetHeight - clampedNextHeight - scrollbarYOffset - thumbYOffset
181
+ const scrollRangeY = scrollableContentHeight - viewportHeight
182
+ const scrollRatioY = scrollRangeY === 0 ? 0 : scrollTop / scrollRangeY
183
+ const thumbOffsetY = Math.min(maxThumbOffsetY, Math.max(0, scrollRatioY * maxThumbOffsetY))
184
+ thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`
185
+ }
186
+
187
+ if (scrollbarXEl && thumbXEl) {
188
+ const maxThumbOffsetX = scrollbarXEl.offsetWidth - clampedNextWidth - scrollbarXOffset - thumbXOffset
189
+ const scrollRangeX = scrollableContentWidth - viewportWidth
190
+ const scrollRatioX = scrollRangeX === 0 ? 0 : scrollLeftFromStart / scrollRangeX
191
+ const thumbOffsetX = direction.value === 'rtl'
192
+ ? -clamp(scrollRatioX * maxThumbOffsetX, 0, maxThumbOffsetX)
193
+ : clamp(scrollRatioX * maxThumbOffsetX, 0, maxThumbOffsetX)
194
+ thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`
195
+ }
196
+
197
+ const overflowMetricsPx: Array<[string, number]> = [
198
+ [ScrollAreaViewportCssVars.scrollAreaOverflowXStart, scrollLeftFromStart],
199
+ [ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, scrollLeftFromEnd],
200
+ [ScrollAreaViewportCssVars.scrollAreaOverflowYStart, scrollTopFromStart],
201
+ [ScrollAreaViewportCssVars.scrollAreaOverflowYEnd, scrollTopFromEnd],
202
+ ]
203
+
204
+ for (const [cssVar, value] of overflowMetricsPx) {
205
+ viewportEl.style.setProperty(cssVar, `${value}px`)
206
+ }
207
+
208
+ if (cornerEl) {
209
+ if (scrollbarXHidden || scrollbarYHidden) {
210
+ setCornerSize({ width: 0, height: 0 })
211
+ }
212
+ else {
213
+ setCornerSize({ width: nextCornerWidth, height: nextCornerHeight })
214
+ }
215
+ }
216
+
217
+ setHiddenState(mergeHiddenState(hiddenState.value, nextHiddenState))
218
+
219
+ const threshold = overflowEdgeThreshold.value
220
+ const nextOverflowEdges = {
221
+ xStart: !scrollbarXHidden && scrollLeftFromStart > threshold.xStart,
222
+ xEnd: !scrollbarXHidden && scrollLeftFromEnd > threshold.xEnd,
223
+ yStart: !scrollbarYHidden && scrollTopFromStart > threshold.yStart,
224
+ yEnd: !scrollbarYHidden && scrollTopFromEnd > threshold.yEnd,
225
+ }
226
+
227
+ const prev = overflowEdges.value
228
+ if (
229
+ prev.xStart !== nextOverflowEdges.xStart
230
+ || prev.xEnd !== nextOverflowEdges.xEnd
231
+ || prev.yStart !== nextOverflowEdges.yStart
232
+ || prev.yEnd !== nextOverflowEdges.yEnd
233
+ ) {
234
+ setOverflowEdges(nextOverflowEdges)
235
+ }
236
+ }
237
+
238
+ function handleUserInteraction() {
239
+ programmaticScroll = false
240
+ }
241
+
242
+ function onScroll() {
243
+ if (!viewportRef.value)
244
+ return
245
+
246
+ computeThumbPosition()
247
+
248
+ if (!programmaticScroll) {
249
+ handleScroll({
250
+ x: viewportRef.value.scrollLeft,
251
+ y: viewportRef.value.scrollTop,
252
+ })
253
+ }
254
+
255
+ clearTimeout(scrollEndTimeoutId)
256
+ scrollEndTimeoutId = setTimeout(() => {
257
+ programmaticScroll = true
258
+ }, 100)
259
+ }
260
+
261
+ const viewportState = computed<ScrollAreaViewportState>(() => ({
262
+ scrolling: scrollingX.value || scrollingY.value,
263
+ hasOverflowX: !hiddenState.value.x,
264
+ hasOverflowY: !hiddenState.value.y,
265
+ overflowXStart: overflowEdges.value.xStart,
266
+ overflowXEnd: overflowEdges.value.xEnd,
267
+ overflowYStart: overflowEdges.value.yStart,
268
+ overflowYEnd: overflowEdges.value.yEnd,
269
+ cornerHidden: hiddenState.value.corner,
270
+ }))
271
+
272
+ const elementProps = computed(() => mergeProps(
273
+ attrs as Record<string, any>,
274
+ {
275
+ role: 'presentation',
276
+ ...(rootId ? { 'data-id': `${rootId}-viewport` } : {}),
277
+ tabindex: hiddenState.value.x && hiddenState.value.y ? -1 : 0,
278
+ class: styleDisableScrollbar.className,
279
+ style: { overflow: 'scroll' },
280
+ onScroll,
281
+ onWheel: handleUserInteraction,
282
+ onTouchmove: handleUserInteraction,
283
+ onPointermove: handleUserInteraction,
284
+ onPointerenter: handleUserInteraction,
285
+ onKeydown: handleUserInteraction,
286
+ },
287
+ ))
288
+
289
+ const {
290
+ tag,
291
+ mergedProps,
292
+ renderless,
293
+ ref: renderRef,
294
+ } = useRenderElement({
295
+ componentProps: props,
296
+ state: viewportState,
297
+ props: elementProps,
298
+ stateAttributesMapping: scrollAreaStateAttributesMapping,
299
+ defaultTagName: 'div',
300
+ ref: viewportRef,
301
+ })
302
+
303
+ onMounted(() => {
304
+ removeCSSVariableInheritance()
305
+
306
+ if (viewportRef.value?.matches(':hover')) {
307
+ setHovering(true)
308
+ }
309
+
310
+ queueMicrotask(computeThumbPosition)
311
+
312
+ const viewport = viewportRef.value
313
+ if (typeof ResizeObserver !== 'undefined' && viewport) {
314
+ let hasInitialized = false
315
+ resizeObserver = new ResizeObserver(() => {
316
+ if (!hasInitialized) {
317
+ hasInitialized = true
318
+ if (
319
+ lastMeasuredViewportMetrics[0] === viewport.clientHeight
320
+ && lastMeasuredViewportMetrics[1] === viewport.scrollHeight
321
+ && lastMeasuredViewportMetrics[2] === viewport.clientWidth
322
+ && lastMeasuredViewportMetrics[3] === viewport.scrollWidth
323
+ ) {
324
+ return
325
+ }
326
+ }
327
+ computeThumbPosition()
328
+ })
329
+ resizeObserver.observe(viewport)
330
+
331
+ waitForAnimationsTimeoutId = setTimeout(() => {
332
+ const animations = viewport.getAnimations({ subtree: true })
333
+ if (animations.length === 0)
334
+ return
335
+ Promise.allSettled(animations.map(a => a.finished))
336
+ .then(computeThumbPosition)
337
+ }, 0)
338
+ }
339
+ })
340
+
341
+ watch([hiddenState, direction], () => {
342
+ queueMicrotask(computeThumbPosition)
343
+ })
344
+
345
+ watch(overflowEdgeThreshold, computeThumbPosition)
346
+
347
+ onBeforeUnmount(() => {
348
+ resizeObserver?.disconnect()
349
+ clearTimeout(scrollEndTimeoutId)
350
+ clearTimeout(waitForAnimationsTimeoutId)
351
+ })
352
+
353
+ provide(scrollAreaViewportContextKey, { computeThumbPosition })
354
+
355
+ function getHiddenState(viewport: HTMLElement): HiddenState {
356
+ const y = viewport.clientHeight >= viewport.scrollHeight
357
+ const x = viewport.clientWidth >= viewport.scrollWidth
358
+ return { y, x, corner: y || x }
359
+ }
360
+
361
+ function mergeHiddenState(prevState: HiddenState, nextState: HiddenState): HiddenState {
362
+ if (prevState.y === nextState.y && prevState.x === nextState.x && prevState.corner === nextState.corner) {
363
+ return prevState
364
+ }
365
+ return nextState
366
+ }
367
+ </script>
368
+
369
+ <template>
370
+ <slot v-if="renderless" :ref="renderRef" :props="mergedProps" :state="viewportState" />
371
+ <component
372
+ :is="tag"
373
+ v-else
374
+ :ref="renderRef"
375
+ v-bind="mergedProps"
376
+ >
377
+ <slot />
378
+ </component>
379
+ </template>
@@ -0,0 +1,20 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import { inject } from 'vue'
3
+
4
+ export interface ScrollAreaViewportContext {
5
+ computeThumbPosition: () => void
6
+ }
7
+
8
+ export const scrollAreaViewportContextKey = Symbol(
9
+ 'ScrollAreaViewportContext',
10
+ ) as InjectionKey<ScrollAreaViewportContext>
11
+
12
+ export function useScrollAreaViewportContext(): ScrollAreaViewportContext {
13
+ const context = inject(scrollAreaViewportContextKey)
14
+ if (context === undefined) {
15
+ throw new Error(
16
+ 'Base UI: ScrollAreaViewportContext is missing. ScrollArea.Content must be placed within <ScrollAreaViewport>.',
17
+ )
18
+ }
19
+ return context
20
+ }
@@ -0,0 +1,6 @@
1
+ export enum ScrollAreaViewportCssVars {
2
+ scrollAreaOverflowXStart = '--scroll-area-overflow-x-start',
3
+ scrollAreaOverflowXEnd = '--scroll-area-overflow-x-end',
4
+ scrollAreaOverflowYStart = '--scroll-area-overflow-y-start',
5
+ scrollAreaOverflowYEnd = '--scroll-area-overflow-y-end',
6
+ }
@@ -0,0 +1,9 @@
1
+ export enum ScrollAreaViewportDataAttributes {
2
+ scrolling = 'data-scrolling',
3
+ hasOverflowX = 'data-has-overflow-x',
4
+ hasOverflowY = 'data-has-overflow-y',
5
+ overflowXStart = 'data-overflow-x-start',
6
+ overflowXEnd = 'data-overflow-x-end',
7
+ overflowYStart = 'data-overflow-y-start',
8
+ overflowYEnd = 'data-overflow-y-end',
9
+ }
@@ -0,0 +1,15 @@
1
+ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
2
+ const platform = typeof navigator !== 'undefined' ? navigator.platform : ''
3
+ const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints : 0
4
+
5
+ export const isWebKit
6
+ = typeof CSS !== 'undefined' && typeof CSS.supports === 'function'
7
+ ? CSS.supports('-webkit-backdrop-filter:none') && !/chrome|android/i.test(ua)
8
+ : /\b(?:iphone|ipad|ipod)\b/i.test(ua)
9
+
10
+ export const isFirefox = /firefox/i.test(ua)
11
+
12
+ export const isIOS
13
+ = /\b(?:iphone|ipad|ipod)\b/i.test(ua)
14
+ // iPadOS reports as "MacIntel" but exposes touch points.
15
+ || (platform === 'MacIntel' && maxTouchPoints > 1)
@@ -1,7 +1,65 @@
1
+ const formatterCache = new Map<string, Intl.NumberFormat>()
2
+
3
+ /**
4
+ * Returns a memoized `Intl.NumberFormat` for the given locale/options.
5
+ */
6
+ export function getFormatter(locale?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions) {
7
+ const optionsString = JSON.stringify({ locale, options })
8
+ const cachedFormatter = formatterCache.get(optionsString)
9
+
10
+ if (cachedFormatter) {
11
+ return cachedFormatter
12
+ }
13
+
14
+ const formatter = new Intl.NumberFormat(locale, options)
15
+ formatterCache.set(optionsString, formatter)
16
+
17
+ return formatter
18
+ }
19
+
1
20
  export function formatNumber(
2
- value: number,
21
+ value: number | null,
3
22
  locale?: Intl.LocalesArgument,
4
23
  options?: Intl.NumberFormatOptions,
5
24
  ) {
6
- return new Intl.NumberFormat(locale, options).format(value)
25
+ if (value == null) {
26
+ return ''
27
+ }
28
+ return getFormatter(locale, options).format(value)
29
+ }
30
+
31
+ export function formatNumberMaxPrecision(
32
+ value: number | null,
33
+ locale?: Intl.LocalesArgument,
34
+ options?: Intl.NumberFormatOptions,
35
+ ) {
36
+ return formatNumber(value, locale, {
37
+ ...options,
38
+ maximumFractionDigits: 20,
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Formats a numeric value for display inside Base UI Vue components.
44
+ *
45
+ * When no `format` is provided, the value is interpreted as a percentage
46
+ * in the 0-100 range (matching React's Base UI semantics for
47
+ * `<Meter.Root>` / `<Progress.Root>`).
48
+ *
49
+ * Returns an empty string when the value is `null`.
50
+ */
51
+ export function formatNumberValue(
52
+ value: number | null,
53
+ locale?: Intl.LocalesArgument,
54
+ format?: Intl.NumberFormatOptions,
55
+ ): string {
56
+ if (value == null) {
57
+ return ''
58
+ }
59
+
60
+ if (!format) {
61
+ return formatNumber(value / 100, locale, { style: 'percent' })
62
+ }
63
+
64
+ return formatNumber(value, locale, format)
7
65
  }
@@ -0,0 +1,33 @@
1
+ import { clamp } from './clamp'
2
+
3
+ export const SCROLL_EDGE_TOLERANCE_PX = 1
4
+
5
+ export function getMaxScrollOffset(scrollSize: number, clientSize: number) {
6
+ return Math.max(0, scrollSize - clientSize)
7
+ }
8
+
9
+ export function normalizeScrollOffset(value: number, max: number) {
10
+ if (max <= 0) {
11
+ return 0
12
+ }
13
+
14
+ const clamped = clamp(value, 0, max)
15
+ const startDistance = clamped
16
+ const endDistance = max - clamped
17
+ const withinStartTolerance = startDistance <= SCROLL_EDGE_TOLERANCE_PX
18
+ const withinEndTolerance = endDistance <= SCROLL_EDGE_TOLERANCE_PX
19
+
20
+ if (withinStartTolerance && withinEndTolerance) {
21
+ return startDistance <= endDistance ? 0 : max
22
+ }
23
+
24
+ if (withinStartTolerance) {
25
+ return 0
26
+ }
27
+
28
+ if (withinEndTolerance) {
29
+ return max
30
+ }
31
+
32
+ return clamped
33
+ }
@@ -0,0 +1,28 @@
1
+ const DISABLE_SCROLLBAR_CLASS_NAME = 'base-ui-disable-scrollbar'
2
+ const STYLE_ELEMENT_ID = `style-${DISABLE_SCROLLBAR_CLASS_NAME}`
3
+
4
+ export const styleDisableScrollbar = {
5
+ className: DISABLE_SCROLLBAR_CLASS_NAME,
6
+
7
+ inject(nonce?: string, disableStyleElements?: boolean) {
8
+ if (disableStyleElements) {
9
+ return
10
+ }
11
+
12
+ if (typeof document === 'undefined') {
13
+ return
14
+ }
15
+
16
+ if (document.getElementById(STYLE_ELEMENT_ID)) {
17
+ return
18
+ }
19
+
20
+ const style = document.createElement('style')
21
+ style.id = STYLE_ELEMENT_ID
22
+ if (nonce) {
23
+ style.nonce = nonce
24
+ }
25
+ style.textContent = `.${DISABLE_SCROLLBAR_CLASS_NAME}{scrollbar-width:none}.${DISABLE_SCROLLBAR_CLASS_NAME}::-webkit-scrollbar{display:none}`
26
+ document.head.appendChild(style)
27
+ },
28
+ }
@@ -0,0 +1,45 @@
1
+ import { onUnmounted } from 'vue'
2
+
3
+ type IntervalId = number
4
+
5
+ const EMPTY = 0 as IntervalId
6
+
7
+ export class Interval {
8
+ currentId: IntervalId = EMPTY
9
+
10
+ static create() {
11
+ return new Interval()
12
+ }
13
+
14
+ /**
15
+ * Repeatedly executes `fn` every `delay` ms, clearing any previously scheduled interval.
16
+ */
17
+ start(delay: number, fn: () => void) {
18
+ this.clear()
19
+ this.currentId = setInterval(fn, delay) as unknown as IntervalId
20
+ }
21
+
22
+ isStarted() {
23
+ return this.currentId !== EMPTY
24
+ }
25
+
26
+ clear = () => {
27
+ if (this.currentId !== EMPTY) {
28
+ clearInterval(this.currentId)
29
+ this.currentId = EMPTY
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * A `setInterval` with automatic cleanup on unmount.
36
+ */
37
+ export function useInterval() {
38
+ const interval = Interval.create()
39
+
40
+ onUnmounted(() => {
41
+ interval.clear()
42
+ })
43
+
44
+ return interval
45
+ }