base-ui-vue 0.2.0 → 0.3.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 (62) hide show
  1. package/dist/content/ScrollAreaContent.cjs +168 -0
  2. package/dist/content/ScrollAreaContent.cjs.map +1 -0
  3. package/dist/content/ScrollAreaContent.js +133 -0
  4. package/dist/content/ScrollAreaContent.js.map +1 -0
  5. package/dist/corner/ScrollAreaCorner.cjs +77 -0
  6. package/dist/corner/ScrollAreaCorner.cjs.map +1 -0
  7. package/dist/corner/ScrollAreaCorner.js +72 -0
  8. package/dist/corner/ScrollAreaCorner.js.map +1 -0
  9. package/dist/index.cjs +33 -0
  10. package/dist/index.d.cts +1067 -352
  11. package/dist/index.d.cts.map +1 -1
  12. package/dist/index.d.ts +1067 -352
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +4 -2
  15. package/dist/index2.cjs +3565 -1373
  16. package/dist/index2.cjs.map +1 -1
  17. package/dist/index2.js +3228 -1204
  18. package/dist/index2.js.map +1 -1
  19. package/package.json +1 -1
  20. package/src/index.ts +4 -0
  21. package/src/input/Input.vue +37 -0
  22. package/src/input/InputDataAttributes.ts +30 -0
  23. package/src/input/index.ts +4 -0
  24. package/src/meter/index.ts +16 -0
  25. package/src/meter/indicator/MeterIndicator.vue +65 -0
  26. package/src/meter/label/MeterLabel.vue +63 -0
  27. package/src/meter/root/MeterRoot.vue +131 -0
  28. package/src/meter/root/MeterRootContext.ts +41 -0
  29. package/src/meter/track/MeterTrack.vue +46 -0
  30. package/src/meter/value/MeterValue.vue +85 -0
  31. package/src/progress/index.ts +23 -0
  32. package/src/progress/indicator/ProgressIndicator.vue +74 -0
  33. package/src/progress/label/ProgressLabel.vue +63 -0
  34. package/src/progress/root/ProgressRoot.vue +160 -0
  35. package/src/progress/root/ProgressRootContext.ts +51 -0
  36. package/src/progress/root/ProgressRootDataAttributes.ts +14 -0
  37. package/src/progress/root/stateAttributesMapping.ts +18 -0
  38. package/src/progress/track/ProgressTrack.vue +48 -0
  39. package/src/progress/value/ProgressValue.vue +92 -0
  40. package/src/scroll-area/constants.ts +2 -0
  41. package/src/scroll-area/content/ScrollAreaContent.vue +87 -0
  42. package/src/scroll-area/corner/ScrollAreaCorner.vue +64 -0
  43. package/src/scroll-area/index.ts +25 -0
  44. package/src/scroll-area/root/ScrollAreaRoot.vue +297 -0
  45. package/src/scroll-area/root/ScrollAreaRootContext.ts +89 -0
  46. package/src/scroll-area/root/ScrollAreaRootCssVars.ts +4 -0
  47. package/src/scroll-area/root/ScrollAreaRootDataAttributes.ts +9 -0
  48. package/src/scroll-area/root/stateAttributes.ts +14 -0
  49. package/src/scroll-area/scrollbar/ScrollAreaScrollbar.vue +263 -0
  50. package/src/scroll-area/scrollbar/ScrollAreaScrollbarContext.ts +20 -0
  51. package/src/scroll-area/scrollbar/ScrollAreaScrollbarCssVars.ts +4 -0
  52. package/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +11 -0
  53. package/src/scroll-area/thumb/ScrollAreaThumb.vue +120 -0
  54. package/src/scroll-area/thumb/ScrollAreaThumbDataAttributes.ts +3 -0
  55. package/src/scroll-area/utils/getOffset.ts +34 -0
  56. package/src/scroll-area/viewport/ScrollAreaViewport.vue +379 -0
  57. package/src/scroll-area/viewport/ScrollAreaViewportContext.ts +20 -0
  58. package/src/scroll-area/viewport/ScrollAreaViewportCssVars.ts +6 -0
  59. package/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts +9 -0
  60. package/src/utils/formatNumber.ts +25 -0
  61. package/src/utils/scrollEdges.ts +33 -0
  62. package/src/utils/styles.ts +28 -0
@@ -0,0 +1,4 @@
1
+ export enum ScrollAreaScrollbarCssVars {
2
+ scrollAreaThumbHeight = '--scroll-area-thumb-height',
3
+ scrollAreaThumbWidth = '--scroll-area-thumb-width',
4
+ }
@@ -0,0 +1,11 @@
1
+ export enum ScrollAreaScrollbarDataAttributes {
2
+ orientation = 'data-orientation',
3
+ hovering = 'data-hovering',
4
+ scrolling = 'data-scrolling',
5
+ hasOverflowX = 'data-has-overflow-x',
6
+ hasOverflowY = 'data-has-overflow-y',
7
+ overflowXStart = 'data-overflow-x-start',
8
+ overflowXEnd = 'data-overflow-x-end',
9
+ overflowYStart = 'data-overflow-y-start',
10
+ overflowYEnd = 'data-overflow-y-end',
11
+ }
@@ -0,0 +1,120 @@
1
+ <script setup lang="ts">
2
+ import type { ComponentPublicInstance } from 'vue'
3
+ import type { BaseUIComponentProps } from '../../utils/types'
4
+ import { computed, shallowRef, useAttrs, watch } from 'vue'
5
+ import { mergeProps } from '../../merge-props/mergeProps'
6
+ import { useRenderElement } from '../../utils/useRenderElement'
7
+ import { useScrollAreaRootContext } from '../root/ScrollAreaRootContext'
8
+ import { useScrollAreaScrollbarContext } from '../scrollbar/ScrollAreaScrollbarContext'
9
+ import { ScrollAreaScrollbarCssVars } from '../scrollbar/ScrollAreaScrollbarCssVars'
10
+
11
+ export interface ScrollAreaThumbState {
12
+ orientation?: 'horizontal' | 'vertical'
13
+ }
14
+
15
+ export interface ScrollAreaThumbProps extends BaseUIComponentProps<ScrollAreaThumbState> {}
16
+
17
+ defineOptions({
18
+ name: 'ScrollAreaThumb',
19
+ inheritAttrs: false,
20
+ })
21
+
22
+ const props = withDefaults(defineProps<ScrollAreaThumbProps>(), {
23
+ as: 'div',
24
+ })
25
+
26
+ const attrs = useAttrs()
27
+
28
+ const {
29
+ thumbYRef,
30
+ thumbXRef,
31
+ handlePointerDown,
32
+ handlePointerMove,
33
+ handlePointerUp,
34
+ setScrollingX,
35
+ setScrollingY,
36
+ hasMeasuredScrollbar,
37
+ } = useScrollAreaRootContext()
38
+
39
+ const { orientation } = useScrollAreaScrollbarContext()
40
+ const thumbElementRef = shallowRef<HTMLDivElement | null>(null)
41
+
42
+ watch(
43
+ [thumbElementRef, orientation],
44
+ ([element, currentOrientation], [previousElement, previousOrientation]) => {
45
+ if (previousElement) {
46
+ const previousRef = previousOrientation === 'vertical' ? thumbYRef : thumbXRef
47
+
48
+ if (previousRef.value === previousElement) {
49
+ previousRef.value = null
50
+ }
51
+ }
52
+
53
+ if (!element) {
54
+ return
55
+ }
56
+
57
+ const nextRef = currentOrientation === 'vertical' ? thumbYRef : thumbXRef
58
+ nextRef.value = element
59
+ },
60
+ )
61
+
62
+ const state = computed<ScrollAreaThumbState>(() => ({
63
+ orientation: orientation.value,
64
+ }))
65
+
66
+ function onPointerUp(event: PointerEvent) {
67
+ if (orientation.value === 'vertical') {
68
+ setScrollingY(false)
69
+ }
70
+ if (orientation.value === 'horizontal') {
71
+ setScrollingX(false)
72
+ }
73
+ handlePointerUp(event)
74
+ }
75
+
76
+ function setThumbElement(element: Element | ComponentPublicInstance | null) {
77
+ thumbElementRef.value = element as HTMLDivElement | null
78
+ }
79
+
80
+ const elementProps = computed(() => mergeProps(
81
+ attrs,
82
+ {
83
+ onPointerdown: handlePointerDown,
84
+ onPointermove: handlePointerMove,
85
+ onPointerup: onPointerUp,
86
+ style: {
87
+ visibility: hasMeasuredScrollbar.value ? undefined : 'hidden',
88
+ ...(orientation.value === 'vertical' && {
89
+ height: `var(${ScrollAreaScrollbarCssVars.scrollAreaThumbHeight})`,
90
+ }),
91
+ ...(orientation.value === 'horizontal' && {
92
+ width: `var(${ScrollAreaScrollbarCssVars.scrollAreaThumbWidth})`,
93
+ }),
94
+ },
95
+ },
96
+ ))
97
+
98
+ const {
99
+ tag,
100
+ mergedProps,
101
+ renderless,
102
+ ref: renderRef,
103
+ } = useRenderElement({
104
+ componentProps: props,
105
+ state,
106
+ props: elementProps,
107
+ defaultTagName: 'div',
108
+ ref: setThumbElement,
109
+ })
110
+ </script>
111
+
112
+ <template>
113
+ <slot v-if="renderless" :ref="renderRef" :props="mergedProps" :state="state" />
114
+ <component
115
+ :is="tag"
116
+ v-else
117
+ :ref="renderRef"
118
+ v-bind="mergedProps"
119
+ />
120
+ </template>
@@ -0,0 +1,3 @@
1
+ export enum ScrollAreaThumbDataAttributes {
2
+ orientation = 'data-orientation',
3
+ }
@@ -0,0 +1,34 @@
1
+ export function getOffset(
2
+ element: Element | null,
3
+ prop: 'margin' | 'padding',
4
+ axis: 'x' | 'y',
5
+ ): number {
6
+ if (!element) {
7
+ return 0
8
+ }
9
+
10
+ const styles = getComputedStyle(element)
11
+ const propAxis = axis === 'x' ? 'Inline' : 'Block'
12
+ const start = getStyleNumber(styles, `${prop}${propAxis}Start`)
13
+ const end = getStyleNumber(styles, `${prop}${propAxis}End`)
14
+
15
+ // Safari misreports `marginInlineEnd` in RTL.
16
+ // We have to assume the start/end values are symmetrical, which is likely.
17
+ if (axis === 'x' && prop === 'margin' && styles.direction === 'rtl' && isSafari()) {
18
+ return start * 2
19
+ }
20
+
21
+ return start + end
22
+ }
23
+
24
+ function getStyleNumber(styles: CSSStyleDeclaration, prop: string) {
25
+ return Number.parseFloat(styles[prop as any]) || 0
26
+ }
27
+
28
+ function isSafari() {
29
+ if (typeof navigator === 'undefined') {
30
+ return false
31
+ }
32
+
33
+ return /AppleWebKit/.test(navigator.userAgent) && !/Chrome|Chromium|Edg\//.test(navigator.userAgent)
34
+ }
@@ -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
+ }
@@ -5,3 +5,28 @@ export function formatNumber(
5
5
  ) {
6
6
  return new Intl.NumberFormat(locale, options).format(value)
7
7
  }
8
+
9
+ /**
10
+ * Formats a numeric value for display inside Base UI Vue components.
11
+ *
12
+ * When no `format` is provided, the value is interpreted as a percentage
13
+ * in the 0-100 range (matching React's Base UI semantics for
14
+ * `<Meter.Root>` / `<Progress.Root>`).
15
+ *
16
+ * Returns an empty string when the value is `null`.
17
+ */
18
+ export function formatNumberValue(
19
+ value: number | null,
20
+ locale?: Intl.LocalesArgument,
21
+ format?: Intl.NumberFormatOptions,
22
+ ): string {
23
+ if (value == null) {
24
+ return ''
25
+ }
26
+
27
+ if (!format) {
28
+ return formatNumber(value / 100, locale, { style: 'percent' })
29
+ }
30
+
31
+ return formatNumber(value, locale, format)
32
+ }
@@ -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
+ }