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.
- package/dist/content/ScrollAreaContent.cjs +168 -0
- package/dist/content/ScrollAreaContent.cjs.map +1 -0
- package/dist/content/ScrollAreaContent.js +133 -0
- package/dist/content/ScrollAreaContent.js.map +1 -0
- package/dist/corner/ScrollAreaCorner.cjs +77 -0
- package/dist/corner/ScrollAreaCorner.cjs.map +1 -0
- package/dist/corner/ScrollAreaCorner.js +72 -0
- package/dist/corner/ScrollAreaCorner.js.map +1 -0
- package/dist/index.cjs +33 -0
- package/dist/index.d.cts +1067 -352
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1067 -352
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index2.cjs +3565 -1373
- package/dist/index2.cjs.map +1 -1
- package/dist/index2.js +3228 -1204
- package/dist/index2.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/input/Input.vue +37 -0
- package/src/input/InputDataAttributes.ts +30 -0
- package/src/input/index.ts +4 -0
- package/src/meter/index.ts +16 -0
- package/src/meter/indicator/MeterIndicator.vue +65 -0
- package/src/meter/label/MeterLabel.vue +63 -0
- package/src/meter/root/MeterRoot.vue +131 -0
- package/src/meter/root/MeterRootContext.ts +41 -0
- package/src/meter/track/MeterTrack.vue +46 -0
- package/src/meter/value/MeterValue.vue +85 -0
- package/src/progress/index.ts +23 -0
- package/src/progress/indicator/ProgressIndicator.vue +74 -0
- package/src/progress/label/ProgressLabel.vue +63 -0
- package/src/progress/root/ProgressRoot.vue +160 -0
- package/src/progress/root/ProgressRootContext.ts +51 -0
- package/src/progress/root/ProgressRootDataAttributes.ts +14 -0
- package/src/progress/root/stateAttributesMapping.ts +18 -0
- package/src/progress/track/ProgressTrack.vue +48 -0
- package/src/progress/value/ProgressValue.vue +92 -0
- package/src/scroll-area/constants.ts +2 -0
- package/src/scroll-area/content/ScrollAreaContent.vue +87 -0
- package/src/scroll-area/corner/ScrollAreaCorner.vue +64 -0
- package/src/scroll-area/index.ts +25 -0
- package/src/scroll-area/root/ScrollAreaRoot.vue +297 -0
- package/src/scroll-area/root/ScrollAreaRootContext.ts +89 -0
- package/src/scroll-area/root/ScrollAreaRootCssVars.ts +4 -0
- package/src/scroll-area/root/ScrollAreaRootDataAttributes.ts +9 -0
- package/src/scroll-area/root/stateAttributes.ts +14 -0
- package/src/scroll-area/scrollbar/ScrollAreaScrollbar.vue +263 -0
- package/src/scroll-area/scrollbar/ScrollAreaScrollbarContext.ts +20 -0
- package/src/scroll-area/scrollbar/ScrollAreaScrollbarCssVars.ts +4 -0
- package/src/scroll-area/scrollbar/ScrollAreaScrollbarDataAttributes.ts +11 -0
- package/src/scroll-area/thumb/ScrollAreaThumb.vue +120 -0
- package/src/scroll-area/thumb/ScrollAreaThumbDataAttributes.ts +3 -0
- package/src/scroll-area/utils/getOffset.ts +34 -0
- package/src/scroll-area/viewport/ScrollAreaViewport.vue +379 -0
- package/src/scroll-area/viewport/ScrollAreaViewportContext.ts +20 -0
- package/src/scroll-area/viewport/ScrollAreaViewportCssVars.ts +6 -0
- package/src/scroll-area/viewport/ScrollAreaViewportDataAttributes.ts +9 -0
- package/src/utils/formatNumber.ts +25 -0
- package/src/utils/scrollEdges.ts +33 -0
- 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,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
|
+
}
|