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,297 @@
1
+ <script setup lang="ts">
2
+ import type { BaseUIComponentProps } from '../../utils/types'
3
+ import type { Coords, HiddenState, OverflowEdges, ScrollAreaRootContext, ScrollAreaRootState, Size } from './ScrollAreaRootContext'
4
+ import { computed, onMounted, provide, ref, shallowRef, useAttrs } from 'vue'
5
+ import { useCSPContext } from '../../csp-provider/CSPContext'
6
+ import { contains } from '../../floating-ui-vue/utils/shadowDom'
7
+ import { mergeProps } from '../../merge-props/mergeProps'
8
+ import { styleDisableScrollbar } from '../../utils/styles'
9
+ import { useBaseUiId } from '../../utils/useBaseUiId'
10
+ import { useRenderElement } from '../../utils/useRenderElement'
11
+ import { SCROLL_TIMEOUT } from '../constants'
12
+ import { ScrollAreaScrollbarDataAttributes } from '../scrollbar/ScrollAreaScrollbarDataAttributes'
13
+ import { getOffset } from '../utils/getOffset'
14
+ import { scrollAreaRootContextKey } from './ScrollAreaRootContext'
15
+ import { ScrollAreaRootCssVars } from './ScrollAreaRootCssVars'
16
+ import { scrollAreaStateAttributesMapping } from './stateAttributes'
17
+
18
+ defineOptions({
19
+ name: 'ScrollAreaRoot',
20
+ inheritAttrs: false,
21
+ })
22
+ const props = withDefaults(defineProps<ScrollAreaRootProps>(), {
23
+ as: 'div',
24
+ })
25
+ const DEFAULT_SIZE: Size = { width: 0, height: 0 }
26
+ const DEFAULT_OVERFLOW_EDGES: OverflowEdges = { xStart: false, xEnd: false, yStart: false, yEnd: false }
27
+ const DEFAULT_HIDDEN_STATE: HiddenState = { x: true, y: true, corner: true }
28
+ const DEFAULT_COORDS: Coords = { x: 0, y: 0 }
29
+
30
+ export interface ScrollAreaRootProps extends BaseUIComponentProps<ScrollAreaRootState> {
31
+ overflowEdgeThreshold?:
32
+ | number
33
+ | Partial<{
34
+ xStart: number
35
+ xEnd: number
36
+ yStart: number
37
+ yEnd: number
38
+ }>
39
+ }
40
+
41
+ const attrs = useAttrs()
42
+
43
+ const overflowEdgeThreshold = computed(() => normalizeOverflowEdgeThreshold(props.overflowEdgeThreshold))
44
+
45
+ const rootId = useBaseUiId()
46
+ const { nonce, disableStyleElements } = useCSPContext()
47
+
48
+ const hovering = ref(false)
49
+ const scrollingX = ref(false)
50
+ const scrollingY = ref(false)
51
+ const touchModality = ref(false)
52
+ const hasMeasuredScrollbar = ref(false)
53
+ const cornerSize = ref<Size>({ ...DEFAULT_SIZE })
54
+ const thumbSize = ref<Size>({ ...DEFAULT_SIZE })
55
+ const overflowEdges = ref<OverflowEdges>({ ...DEFAULT_OVERFLOW_EDGES })
56
+ const hiddenState = ref<HiddenState>({ ...DEFAULT_HIDDEN_STATE })
57
+
58
+ const rootRef = shallowRef<HTMLDivElement | null>(null)
59
+ const viewportRef = shallowRef<HTMLDivElement | null>(null)
60
+ const scrollbarYRef = shallowRef<HTMLDivElement | null>(null)
61
+ const scrollbarXRef = shallowRef<HTMLDivElement | null>(null)
62
+ const thumbYRef = shallowRef<HTMLDivElement | null>(null)
63
+ const thumbXRef = shallowRef<HTMLDivElement | null>(null)
64
+ const cornerRef = shallowRef<HTMLDivElement | null>(null)
65
+
66
+ let scrollYTimeoutId: ReturnType<typeof setTimeout> | undefined
67
+ let scrollXTimeoutId: ReturnType<typeof setTimeout> | undefined
68
+
69
+ // Pointer drag baselines are mutable so move events don't trigger Vue renders.
70
+ const thumbDragging = { current: false }
71
+ const startY = { current: 0 }
72
+ const startX = { current: 0 }
73
+ const startScrollTop = { current: 0 }
74
+ const startScrollLeft = { current: 0 }
75
+ const currentOrientation = { current: 'vertical' as 'vertical' | 'horizontal' }
76
+ const scrollPosition = { current: { ...DEFAULT_COORDS } }
77
+
78
+ function handleScroll(pos: Coords) {
79
+ const offsetX = pos.x - scrollPosition.current.x
80
+ const offsetY = pos.y - scrollPosition.current.y
81
+ scrollPosition.current = pos
82
+
83
+ if (offsetY !== 0) {
84
+ scrollingY.value = true
85
+ clearTimeout(scrollYTimeoutId)
86
+ scrollYTimeoutId = setTimeout(() => {
87
+ scrollingY.value = false
88
+ }, SCROLL_TIMEOUT)
89
+ }
90
+
91
+ if (offsetX !== 0) {
92
+ scrollingX.value = true
93
+ clearTimeout(scrollXTimeoutId)
94
+ scrollXTimeoutId = setTimeout(() => {
95
+ scrollingX.value = false
96
+ }, SCROLL_TIMEOUT)
97
+ }
98
+ }
99
+
100
+ function handlePointerDown(event: PointerEvent) {
101
+ if (event.button !== 0)
102
+ return
103
+
104
+ thumbDragging.current = true
105
+ startY.current = event.clientY
106
+ startX.current = event.clientX
107
+ currentOrientation.current = (event.currentTarget as Element).getAttribute(
108
+ ScrollAreaScrollbarDataAttributes.orientation,
109
+ ) as 'vertical' | 'horizontal'
110
+
111
+ if (viewportRef.value) {
112
+ startScrollTop.current = viewportRef.value.scrollTop
113
+ startScrollLeft.current = viewportRef.value.scrollLeft
114
+ }
115
+ if (thumbYRef.value && currentOrientation.current === 'vertical') {
116
+ thumbYRef.value.setPointerCapture(event.pointerId)
117
+ }
118
+ if (thumbXRef.value && currentOrientation.current === 'horizontal') {
119
+ thumbXRef.value.setPointerCapture(event.pointerId)
120
+ }
121
+ }
122
+
123
+ function handlePointerMove(event: PointerEvent) {
124
+ if (!thumbDragging.current)
125
+ return
126
+
127
+ const deltaY = event.clientY - startY.current
128
+ const deltaX = event.clientX - startX.current
129
+
130
+ if (viewportRef.value) {
131
+ const scrollableContentHeight = viewportRef.value.scrollHeight
132
+ const viewportHeight = viewportRef.value.clientHeight
133
+ const scrollableContentWidth = viewportRef.value.scrollWidth
134
+ const viewportWidth = viewportRef.value.clientWidth
135
+
136
+ if (thumbYRef.value && scrollbarYRef.value && currentOrientation.current === 'vertical') {
137
+ const scrollbarYOffset = getOffset(scrollbarYRef.value, 'padding', 'y')
138
+ const thumbYOffset = getOffset(thumbYRef.value, 'margin', 'y')
139
+ const thumbHeight = thumbYRef.value.offsetHeight
140
+ const maxThumbOffsetY = scrollbarYRef.value.offsetHeight - thumbHeight - scrollbarYOffset - thumbYOffset
141
+ const scrollRatioY = deltaY / maxThumbOffsetY
142
+ viewportRef.value.scrollTop = startScrollTop.current + scrollRatioY * (scrollableContentHeight - viewportHeight)
143
+ event.preventDefault()
144
+ scrollingY.value = true
145
+ clearTimeout(scrollYTimeoutId)
146
+ scrollYTimeoutId = setTimeout(() => {
147
+ scrollingY.value = false
148
+ }, SCROLL_TIMEOUT)
149
+ }
150
+
151
+ if (thumbXRef.value && scrollbarXRef.value && currentOrientation.current === 'horizontal') {
152
+ const scrollbarXOffset = getOffset(scrollbarXRef.value, 'padding', 'x')
153
+ const thumbXOffset = getOffset(thumbXRef.value, 'margin', 'x')
154
+ const thumbWidth = thumbXRef.value.offsetWidth
155
+ const maxThumbOffsetX = scrollbarXRef.value.offsetWidth - thumbWidth - scrollbarXOffset - thumbXOffset
156
+ const scrollRatioX = deltaX / maxThumbOffsetX
157
+ viewportRef.value.scrollLeft = startScrollLeft.current + scrollRatioX * (scrollableContentWidth - viewportWidth)
158
+ event.preventDefault()
159
+ scrollingX.value = true
160
+ clearTimeout(scrollXTimeoutId)
161
+ scrollXTimeoutId = setTimeout(() => {
162
+ scrollingX.value = false
163
+ }, SCROLL_TIMEOUT)
164
+ }
165
+ }
166
+ }
167
+
168
+ function handlePointerUp(event: PointerEvent) {
169
+ thumbDragging.current = false
170
+ if (thumbYRef.value && currentOrientation.current === 'vertical') {
171
+ thumbYRef.value.releasePointerCapture(event.pointerId)
172
+ }
173
+ if (thumbXRef.value && currentOrientation.current === 'horizontal') {
174
+ thumbXRef.value.releasePointerCapture(event.pointerId)
175
+ }
176
+ }
177
+
178
+ function handleTouchModalityChange(event: PointerEvent) {
179
+ touchModality.value = event.pointerType === 'touch'
180
+ }
181
+
182
+ function handlePointerEnterOrMove(event: PointerEvent) {
183
+ handleTouchModalityChange(event)
184
+ if (event.pointerType !== 'touch') {
185
+ const isTargetRootChild = contains(rootRef.value, event.target as Element)
186
+ hovering.value = isTargetRootChild
187
+ }
188
+ }
189
+
190
+ const state = computed<ScrollAreaRootState>(() => ({
191
+ scrolling: scrollingX.value || scrollingY.value,
192
+ hasOverflowX: !hiddenState.value.x,
193
+ hasOverflowY: !hiddenState.value.y,
194
+ overflowXStart: overflowEdges.value.xStart,
195
+ overflowXEnd: overflowEdges.value.xEnd,
196
+ overflowYStart: overflowEdges.value.yStart,
197
+ overflowYEnd: overflowEdges.value.yEnd,
198
+ cornerHidden: hiddenState.value.corner,
199
+ }))
200
+
201
+ const elementProps = computed(() => mergeProps(
202
+ attrs as Record<string, any>,
203
+ {
204
+ role: 'presentation',
205
+ onPointerenter: handlePointerEnterOrMove,
206
+ onPointermove: handlePointerEnterOrMove,
207
+ onPointerdown: handleTouchModalityChange,
208
+ onPointerleave: () => { hovering.value = false },
209
+ style: {
210
+ position: 'relative',
211
+ [ScrollAreaRootCssVars.scrollAreaCornerHeight]: `${cornerSize.value.height}px`,
212
+ [ScrollAreaRootCssVars.scrollAreaCornerWidth]: `${cornerSize.value.width}px`,
213
+ },
214
+ },
215
+ ))
216
+
217
+ const {
218
+ tag,
219
+ mergedProps,
220
+ renderless,
221
+ ref: renderRef,
222
+ } = useRenderElement({
223
+ componentProps: props,
224
+ state,
225
+ props: elementProps,
226
+ stateAttributesMapping: scrollAreaStateAttributesMapping,
227
+ defaultTagName: 'div',
228
+ ref: rootRef,
229
+ })
230
+
231
+ onMounted(() => {
232
+ styleDisableScrollbar.inject(nonce.value, disableStyleElements.value)
233
+ })
234
+
235
+ const contextValue: ScrollAreaRootContext = {
236
+ cornerSize,
237
+ setCornerSize: (size: Size) => { cornerSize.value = size },
238
+ thumbSize,
239
+ setThumbSize: (size: Size) => { thumbSize.value = size },
240
+ hasMeasuredScrollbar,
241
+ setHasMeasuredScrollbar: (value: boolean) => { hasMeasuredScrollbar.value = value },
242
+ touchModality,
243
+ hovering,
244
+ setHovering: (value: boolean) => { hovering.value = value },
245
+ scrollingX,
246
+ setScrollingX: (value: boolean) => { scrollingX.value = value },
247
+ scrollingY,
248
+ setScrollingY: (value: boolean) => { scrollingY.value = value },
249
+ viewportRef,
250
+ rootRef,
251
+ scrollbarYRef,
252
+ scrollbarXRef,
253
+ thumbYRef,
254
+ thumbXRef,
255
+ cornerRef,
256
+ handlePointerDown,
257
+ handlePointerMove,
258
+ handlePointerUp,
259
+ handleScroll,
260
+ rootId,
261
+ hiddenState,
262
+ setHiddenState: (s: HiddenState) => { hiddenState.value = s },
263
+ overflowEdges,
264
+ setOverflowEdges: (e: OverflowEdges) => { overflowEdges.value = e },
265
+ viewportState: state,
266
+ overflowEdgeThreshold,
267
+ }
268
+
269
+ provide(scrollAreaRootContextKey, contextValue)
270
+
271
+ function normalizeOverflowEdgeThreshold(
272
+ threshold: ScrollAreaRootProps['overflowEdgeThreshold'],
273
+ ) {
274
+ if (typeof threshold === 'number') {
275
+ const value = Math.max(0, threshold)
276
+ return { xStart: value, xEnd: value, yStart: value, yEnd: value }
277
+ }
278
+ return {
279
+ xStart: Math.max(0, threshold?.xStart || 0),
280
+ xEnd: Math.max(0, threshold?.xEnd || 0),
281
+ yStart: Math.max(0, threshold?.yStart || 0),
282
+ yEnd: Math.max(0, threshold?.yEnd || 0),
283
+ }
284
+ }
285
+ </script>
286
+
287
+ <template>
288
+ <slot v-if="renderless" :ref="renderRef" :props="mergedProps" :state="state" />
289
+ <component
290
+ :is="tag"
291
+ v-else
292
+ :ref="renderRef"
293
+ v-bind="mergedProps"
294
+ >
295
+ <slot />
296
+ </component>
297
+ </template>
@@ -0,0 +1,89 @@
1
+ import type { ComputedRef, InjectionKey, Ref, ShallowRef } from 'vue'
2
+ import { inject } from 'vue'
3
+
4
+ export interface Coords {
5
+ x: number
6
+ y: number
7
+ }
8
+
9
+ export interface Size {
10
+ width: number
11
+ height: number
12
+ }
13
+
14
+ export interface HiddenState {
15
+ x: boolean
16
+ y: boolean
17
+ corner: boolean
18
+ }
19
+
20
+ export interface OverflowEdges {
21
+ xStart: boolean
22
+ xEnd: boolean
23
+ yStart: boolean
24
+ yEnd: boolean
25
+ }
26
+
27
+ export interface ScrollAreaRootContext {
28
+ cornerSize: Ref<Size>
29
+ setCornerSize: (size: Size) => void
30
+ thumbSize: Ref<Size>
31
+ setThumbSize: (size: Size) => void
32
+ hasMeasuredScrollbar: Ref<boolean>
33
+ setHasMeasuredScrollbar: (value: boolean) => void
34
+ touchModality: Ref<boolean>
35
+ hovering: Ref<boolean>
36
+ setHovering: (value: boolean) => void
37
+ scrollingX: Ref<boolean>
38
+ setScrollingX: (value: boolean) => void
39
+ scrollingY: Ref<boolean>
40
+ setScrollingY: (value: boolean) => void
41
+ viewportRef: ShallowRef<HTMLDivElement | null>
42
+ rootRef: ShallowRef<HTMLDivElement | null>
43
+ scrollbarYRef: ShallowRef<HTMLDivElement | null>
44
+ scrollbarXRef: ShallowRef<HTMLDivElement | null>
45
+ thumbYRef: ShallowRef<HTMLDivElement | null>
46
+ thumbXRef: ShallowRef<HTMLDivElement | null>
47
+ cornerRef: ShallowRef<HTMLDivElement | null>
48
+ handlePointerDown: (event: PointerEvent) => void
49
+ handlePointerMove: (event: PointerEvent) => void
50
+ handlePointerUp: (event: PointerEvent) => void
51
+ handleScroll: (scrollPosition: Coords) => void
52
+ rootId: string | undefined
53
+ hiddenState: Ref<HiddenState>
54
+ setHiddenState: (state: HiddenState) => void
55
+ overflowEdges: Ref<OverflowEdges>
56
+ setOverflowEdges: (edges: OverflowEdges) => void
57
+ viewportState: ComputedRef<ScrollAreaRootState>
58
+ overflowEdgeThreshold: ComputedRef<{
59
+ xStart: number
60
+ xEnd: number
61
+ yStart: number
62
+ yEnd: number
63
+ }>
64
+ }
65
+
66
+ export interface ScrollAreaRootState {
67
+ scrolling: boolean
68
+ hasOverflowX: boolean
69
+ hasOverflowY: boolean
70
+ overflowXStart: boolean
71
+ overflowXEnd: boolean
72
+ overflowYStart: boolean
73
+ overflowYEnd: boolean
74
+ cornerHidden: boolean
75
+ }
76
+
77
+ export const scrollAreaRootContextKey = Symbol(
78
+ 'ScrollAreaRootContext',
79
+ ) as InjectionKey<ScrollAreaRootContext>
80
+
81
+ export function useScrollAreaRootContext(): ScrollAreaRootContext {
82
+ const context = inject(scrollAreaRootContextKey)
83
+ if (context === undefined) {
84
+ throw new Error(
85
+ 'Base UI: ScrollAreaRootContext is missing. ScrollArea parts must be placed within <ScrollAreaRoot>.',
86
+ )
87
+ }
88
+ return context
89
+ }
@@ -0,0 +1,4 @@
1
+ export enum ScrollAreaRootCssVars {
2
+ scrollAreaCornerHeight = '--scroll-area-corner-height',
3
+ scrollAreaCornerWidth = '--scroll-area-corner-width',
4
+ }
@@ -0,0 +1,9 @@
1
+ export enum ScrollAreaRootDataAttributes {
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,14 @@
1
+ import type { StateAttributesMapping } from '../../utils/getStateAttributesProps'
2
+ import type { ScrollAreaRootState } from './ScrollAreaRootContext'
3
+ import { ScrollAreaRootDataAttributes } from './ScrollAreaRootDataAttributes'
4
+
5
+ export const scrollAreaStateAttributesMapping: StateAttributesMapping<ScrollAreaRootState> = {
6
+ scrolling: value => (value ? { [ScrollAreaRootDataAttributes.scrolling]: '' } : null),
7
+ hasOverflowX: value => (value ? { [ScrollAreaRootDataAttributes.hasOverflowX]: '' } : null),
8
+ hasOverflowY: value => (value ? { [ScrollAreaRootDataAttributes.hasOverflowY]: '' } : null),
9
+ overflowXStart: value => (value ? { [ScrollAreaRootDataAttributes.overflowXStart]: '' } : null),
10
+ overflowXEnd: value => (value ? { [ScrollAreaRootDataAttributes.overflowXEnd]: '' } : null),
11
+ overflowYStart: value => (value ? { [ScrollAreaRootDataAttributes.overflowYStart]: '' } : null),
12
+ overflowYEnd: value => (value ? { [ScrollAreaRootDataAttributes.overflowYEnd]: '' } : null),
13
+ cornerHidden: () => null,
14
+ }
@@ -0,0 +1,263 @@
1
+ <script setup lang="ts">
2
+ import type { ComponentPublicInstance } from 'vue'
3
+ import type { BaseUIComponentProps } from '../../utils/types'
4
+ import type { ScrollAreaRootState } from '../root/ScrollAreaRootContext'
5
+ import { computed, onBeforeUnmount, provide, shallowRef, useAttrs, watch } from 'vue'
6
+ import { useDirection } from '../../direction-provider/DirectionContext'
7
+ import { contains, getTarget } from '../../floating-ui-vue/utils/shadowDom'
8
+ import { mergeProps } from '../../merge-props/mergeProps'
9
+ import { useRenderElement } from '../../utils/useRenderElement'
10
+ import { useScrollAreaRootContext } from '../root/ScrollAreaRootContext'
11
+ import { ScrollAreaRootCssVars } from '../root/ScrollAreaRootCssVars'
12
+ import { scrollAreaStateAttributesMapping } from '../root/stateAttributes'
13
+ import { getOffset } from '../utils/getOffset'
14
+ import { scrollAreaScrollbarContextKey } from './ScrollAreaScrollbarContext'
15
+ import { ScrollAreaScrollbarCssVars } from './ScrollAreaScrollbarCssVars'
16
+
17
+ export interface ScrollAreaScrollbarState extends ScrollAreaRootState {
18
+ hovering: boolean
19
+ scrolling: boolean
20
+ orientation: 'vertical' | 'horizontal'
21
+ }
22
+
23
+ export interface ScrollAreaScrollbarProps extends BaseUIComponentProps<ScrollAreaScrollbarState> {
24
+ orientation?: 'vertical' | 'horizontal'
25
+ keepMounted?: boolean
26
+ }
27
+
28
+ defineOptions({
29
+ name: 'ScrollAreaScrollbar',
30
+ inheritAttrs: false,
31
+ })
32
+
33
+ const props = withDefaults(defineProps<ScrollAreaScrollbarProps>(), {
34
+ as: 'div',
35
+ orientation: 'vertical',
36
+ keepMounted: false,
37
+ })
38
+
39
+ const attrs = useAttrs()
40
+
41
+ const {
42
+ hovering,
43
+ scrollingX,
44
+ scrollingY,
45
+ hiddenState,
46
+ overflowEdges,
47
+ scrollbarYRef,
48
+ scrollbarXRef,
49
+ viewportRef,
50
+ thumbYRef,
51
+ thumbXRef,
52
+ handlePointerDown,
53
+ handlePointerUp,
54
+ rootId,
55
+ thumbSize,
56
+ hasMeasuredScrollbar,
57
+ } = useScrollAreaRootContext()
58
+
59
+ const direction = useDirection()
60
+
61
+ let wheelCleanup: (() => void) | undefined
62
+ const scrollbarElementRef = shallowRef<HTMLDivElement | null>(null)
63
+
64
+ watch(
65
+ [scrollbarElementRef, () => props.orientation],
66
+ ([element, orientation], [previousElement, previousOrientation]) => {
67
+ if (previousElement) {
68
+ const previousRef = previousOrientation === 'vertical' ? scrollbarYRef : scrollbarXRef
69
+
70
+ if (previousRef.value === previousElement) {
71
+ previousRef.value = null
72
+ }
73
+ }
74
+
75
+ if (!element) {
76
+ return
77
+ }
78
+
79
+ const nextRef = orientation === 'vertical' ? scrollbarYRef : scrollbarXRef
80
+ nextRef.value = element
81
+ },
82
+ )
83
+
84
+ watch(
85
+ [scrollbarElementRef, viewportRef, () => props.orientation],
86
+ () => {
87
+ wheelCleanup?.()
88
+ wheelCleanup = setupWheelHandler()
89
+ },
90
+ { flush: 'post' },
91
+ )
92
+
93
+ onBeforeUnmount(() => {
94
+ wheelCleanup?.()
95
+ })
96
+
97
+ function setupWheelHandler() {
98
+ const viewportEl = viewportRef.value
99
+ const scrollbarEl = scrollbarElementRef.value
100
+
101
+ if (!scrollbarEl)
102
+ return undefined
103
+
104
+ function handleWheel(event: WheelEvent) {
105
+ if (!viewportEl || !scrollbarEl || event.ctrlKey)
106
+ return
107
+
108
+ if (props.orientation === 'vertical') {
109
+ if (viewportEl.scrollTop === 0 && event.deltaY < 0)
110
+ return
111
+ if (viewportEl.scrollTop === viewportEl.scrollHeight - viewportEl.clientHeight && event.deltaY > 0)
112
+ return
113
+ event.preventDefault()
114
+ viewportEl.scrollTop += event.deltaY
115
+ }
116
+ else {
117
+ if (viewportEl.scrollLeft === 0 && event.deltaX < 0)
118
+ return
119
+ if (viewportEl.scrollLeft === viewportEl.scrollWidth - viewportEl.clientWidth && event.deltaX > 0)
120
+ return
121
+ event.preventDefault()
122
+ viewportEl.scrollLeft += event.deltaX
123
+ }
124
+ }
125
+
126
+ scrollbarEl.addEventListener('wheel', handleWheel, { passive: false })
127
+ return () => scrollbarEl.removeEventListener('wheel', handleWheel)
128
+ }
129
+
130
+ function setScrollbarElement(element: Element | ComponentPublicInstance | null) {
131
+ scrollbarElementRef.value = element as HTMLDivElement | null
132
+ }
133
+
134
+ const state = computed<ScrollAreaScrollbarState>(() => ({
135
+ hovering: hovering.value,
136
+ scrolling: props.orientation === 'horizontal' ? scrollingX.value : scrollingY.value,
137
+ orientation: props.orientation,
138
+ hasOverflowX: !hiddenState.value.x,
139
+ hasOverflowY: !hiddenState.value.y,
140
+ overflowXStart: overflowEdges.value.xStart,
141
+ overflowXEnd: overflowEdges.value.xEnd,
142
+ overflowYStart: overflowEdges.value.yStart,
143
+ overflowYEnd: overflowEdges.value.yEnd,
144
+ cornerHidden: hiddenState.value.corner,
145
+ }))
146
+
147
+ const hideTrackUntilMeasured = computed(() => !hasMeasuredScrollbar.value && !props.keepMounted)
148
+
149
+ function onPointerDown(event: PointerEvent) {
150
+ if (event.button !== 0)
151
+ return
152
+
153
+ const target = getTarget(event) as Element | null
154
+ const thumb = props.orientation === 'vertical' ? thumbYRef.value : thumbXRef.value
155
+
156
+ if (thumb && contains(thumb, target))
157
+ return
158
+ if (!viewportRef.value)
159
+ return
160
+
161
+ if (thumbYRef.value && scrollbarYRef.value && props.orientation === 'vertical') {
162
+ const thumbYOffset = getOffset(thumbYRef.value, 'margin', 'y')
163
+ const scrollbarYOffset = getOffset(scrollbarYRef.value, 'padding', 'y')
164
+ const thumbHeight = thumbYRef.value.offsetHeight
165
+ const trackRectY = scrollbarYRef.value.getBoundingClientRect()
166
+ const clickY = event.clientY - trackRectY.top - thumbHeight / 2 - scrollbarYOffset + thumbYOffset / 2
167
+ const scrollableContentHeight = viewportRef.value.scrollHeight
168
+ const viewportHeight = viewportRef.value.clientHeight
169
+ const maxThumbOffsetY = scrollbarYRef.value.offsetHeight - thumbHeight - scrollbarYOffset - thumbYOffset
170
+ const scrollRatioY = clickY / maxThumbOffsetY
171
+ viewportRef.value.scrollTop = scrollRatioY * (scrollableContentHeight - viewportHeight)
172
+ }
173
+
174
+ if (thumbXRef.value && scrollbarXRef.value && props.orientation === 'horizontal') {
175
+ const thumbXOffset = getOffset(thumbXRef.value, 'margin', 'x')
176
+ const scrollbarXOffset = getOffset(scrollbarXRef.value, 'padding', 'x')
177
+ const thumbWidth = thumbXRef.value.offsetWidth
178
+ const trackRectX = scrollbarXRef.value.getBoundingClientRect()
179
+ const clickX = event.clientX - trackRectX.left - thumbWidth / 2 - scrollbarXOffset + thumbXOffset / 2
180
+ const scrollableContentWidth = viewportRef.value.scrollWidth
181
+ const viewportWidth = viewportRef.value.clientWidth
182
+ const maxThumbOffsetX = scrollbarXRef.value.offsetWidth - thumbWidth - scrollbarXOffset - thumbXOffset
183
+ const scrollRatioX = clickX / maxThumbOffsetX
184
+
185
+ let newScrollLeft: number
186
+ if (direction.value === 'rtl') {
187
+ newScrollLeft = (1 - scrollRatioX) * (scrollableContentWidth - viewportWidth)
188
+ if (viewportRef.value.scrollLeft <= 0) {
189
+ newScrollLeft = -newScrollLeft
190
+ }
191
+ }
192
+ else {
193
+ newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth)
194
+ }
195
+ viewportRef.value.scrollLeft = newScrollLeft
196
+ }
197
+
198
+ handlePointerDown(event)
199
+ }
200
+
201
+ const elementProps = computed(() => mergeProps(
202
+ attrs as Record<string, any>,
203
+ {
204
+ ...(rootId ? { 'data-id': `${rootId}-scrollbar` } : {}),
205
+ onPointerdown: onPointerDown,
206
+ onPointerup: handlePointerUp,
207
+ style: {
208
+ position: 'absolute',
209
+ touchAction: 'none',
210
+ WebkitUserSelect: 'none',
211
+ userSelect: 'none',
212
+ visibility: hideTrackUntilMeasured.value ? 'hidden' : undefined,
213
+ ...(props.orientation === 'vertical' && {
214
+ top: 0,
215
+ bottom: `var(${ScrollAreaRootCssVars.scrollAreaCornerHeight})`,
216
+ insetInlineEnd: 0,
217
+ [ScrollAreaScrollbarCssVars.scrollAreaThumbHeight]: `${thumbSize.value.height}px`,
218
+ }),
219
+ ...(props.orientation === 'horizontal' && {
220
+ insetInlineStart: 0,
221
+ insetInlineEnd: `var(${ScrollAreaRootCssVars.scrollAreaCornerWidth})`,
222
+ bottom: 0,
223
+ [ScrollAreaScrollbarCssVars.scrollAreaThumbWidth]: `${thumbSize.value.width}px`,
224
+ }),
225
+ },
226
+ },
227
+ ))
228
+
229
+ const {
230
+ tag,
231
+ mergedProps,
232
+ renderless,
233
+ ref: renderRef,
234
+ } = useRenderElement({
235
+ componentProps: props,
236
+ state,
237
+ props: elementProps,
238
+ stateAttributesMapping: scrollAreaStateAttributesMapping,
239
+ defaultTagName: 'div',
240
+ ref: setScrollbarElement,
241
+ })
242
+
243
+ const isHidden = computed(() => props.orientation === 'vertical' ? hiddenState.value.y : hiddenState.value.x)
244
+ const shouldRender = computed(() => props.keepMounted || !isHidden.value)
245
+
246
+ const orientation = computed(() => props.orientation)
247
+
248
+ provide(scrollAreaScrollbarContextKey, { orientation })
249
+ </script>
250
+
251
+ <template>
252
+ <template v-if="shouldRender">
253
+ <slot v-if="renderless" :ref="renderRef" :props="mergedProps" :state="state" />
254
+ <component
255
+ :is="tag"
256
+ v-else
257
+ :ref="renderRef"
258
+ v-bind="mergedProps"
259
+ >
260
+ <slot />
261
+ </component>
262
+ </template>
263
+ </template>
@@ -0,0 +1,20 @@
1
+ import type { ComputedRef, InjectionKey } from 'vue'
2
+ import { inject } from 'vue'
3
+
4
+ export interface ScrollAreaScrollbarContext {
5
+ orientation: ComputedRef<'horizontal' | 'vertical'>
6
+ }
7
+
8
+ export const scrollAreaScrollbarContextKey: InjectionKey<ScrollAreaScrollbarContext> = Symbol(
9
+ 'ScrollAreaScrollbarContext',
10
+ )
11
+
12
+ export function useScrollAreaScrollbarContext(): ScrollAreaScrollbarContext {
13
+ const context = inject(scrollAreaScrollbarContextKey)
14
+ if (context === undefined) {
15
+ throw new Error(
16
+ 'Base UI: ScrollAreaScrollbarContext is missing. ScrollArea.Thumb must be placed within <ScrollAreaScrollbar>.',
17
+ )
18
+ }
19
+ return context
20
+ }