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.
- package/dist/button/ToolbarButton.cjs +6 -0
- package/dist/button/ToolbarButton.js +1 -1
- 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/control/SliderControl.js +2 -2
- 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/decrement/NumberFieldDecrement.cjs +861 -0
- package/dist/decrement/NumberFieldDecrement.cjs.map +1 -0
- package/dist/decrement/NumberFieldDecrement.js +700 -0
- package/dist/decrement/NumberFieldDecrement.js.map +1 -0
- package/dist/fallback/AvatarFallback.cjs +2 -46
- package/dist/fallback/AvatarFallback.cjs.map +1 -1
- package/dist/fallback/AvatarFallback.js +3 -41
- package/dist/fallback/AvatarFallback.js.map +1 -1
- package/dist/group/NumberFieldGroup.cjs +72 -0
- package/dist/group/NumberFieldGroup.cjs.map +1 -0
- package/dist/group/NumberFieldGroup.js +67 -0
- package/dist/group/NumberFieldGroup.js.map +1 -0
- package/dist/increment/NumberFieldIncrement.cjs +112 -0
- package/dist/increment/NumberFieldIncrement.cjs.map +1 -0
- package/dist/increment/NumberFieldIncrement.js +107 -0
- package/dist/increment/NumberFieldIncrement.js.map +1 -0
- package/dist/index.cjs +52 -0
- package/dist/index.d.cts +1761 -430
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1761 -430
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index2.cjs +4065 -60
- package/dist/index2.cjs.map +1 -1
- package/dist/index2.js +3955 -184
- package/dist/index2.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +6 -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/number-field/decrement/NumberFieldDecrement.vue +109 -0
- package/src/number-field/group/NumberFieldGroup.vue +47 -0
- package/src/number-field/increment/NumberFieldIncrement.vue +109 -0
- package/src/number-field/index.ts +42 -0
- package/src/number-field/input/NumberFieldInput.vue +455 -0
- package/src/number-field/root/NumberFieldRoot.vue +626 -0
- package/src/number-field/root/NumberFieldRootContext.ts +94 -0
- package/src/number-field/root/useNumberFieldButton.ts +171 -0
- package/src/number-field/scrub-area/NumberFieldScrubArea.vue +359 -0
- package/src/number-field/scrub-area/NumberFieldScrubAreaContext.ts +26 -0
- package/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.vue +75 -0
- package/src/number-field/utils/constants.ts +4 -0
- package/src/number-field/utils/getViewportRect.ts +34 -0
- package/src/number-field/utils/parse.ts +248 -0
- package/src/number-field/utils/stateAttributesMapping.ts +9 -0
- package/src/number-field/utils/subscribeToVisualViewportResize.ts +27 -0
- package/src/number-field/utils/types.ts +24 -0
- package/src/number-field/utils/validate.ts +120 -0
- package/src/otp-field/index.ts +22 -0
- package/src/otp-field/input/OtpFieldInput.vue +336 -0
- package/src/otp-field/root/OtpFieldRoot.vue +583 -0
- package/src/otp-field/root/OtpFieldRootContext.ts +81 -0
- package/src/otp-field/utils/otp.ts +135 -0
- package/src/otp-field/utils/stateAttributesMapping.ts +16 -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/detectBrowser.ts +15 -0
- package/src/utils/formatNumber.ts +60 -2
- package/src/utils/scrollEdges.ts +33 -0
- package/src/utils/styles.ts +28 -0
- package/src/utils/useInterval.ts +45 -0
- package/src/utils/usePressAndHold.ts +260 -0
- package/src/utils/useValueChanged.ts +21 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { ComputedRef, Ref } from 'vue'
|
|
2
|
+
import type {
|
|
3
|
+
Direction,
|
|
4
|
+
EventWithOptionalKeyState,
|
|
5
|
+
IncrementValueParameters,
|
|
6
|
+
} from '../utils/types'
|
|
7
|
+
import type {
|
|
8
|
+
NumberFieldRootChangeEventDetails,
|
|
9
|
+
NumberFieldRootChangeEventReason,
|
|
10
|
+
NumberFieldRootCommitEventDetails,
|
|
11
|
+
} from './NumberFieldRootContext'
|
|
12
|
+
import { computed } from 'vue'
|
|
13
|
+
import {
|
|
14
|
+
createChangeEventDetails,
|
|
15
|
+
createGenericEventDetails,
|
|
16
|
+
} from '../../utils/createBaseUIEventDetails'
|
|
17
|
+
import { REASONS } from '../../utils/reasons'
|
|
18
|
+
import { usePressAndHold } from '../../utils/usePressAndHold'
|
|
19
|
+
import {
|
|
20
|
+
CHANGE_VALUE_TICK_DELAY,
|
|
21
|
+
DEFAULT_STEP,
|
|
22
|
+
SCROLLING_POINTER_MOVE_DISTANCE,
|
|
23
|
+
START_AUTO_CHANGE_DELAY,
|
|
24
|
+
} from '../utils/constants'
|
|
25
|
+
import { parseNumber } from '../utils/parse'
|
|
26
|
+
|
|
27
|
+
// Treat pen as touch-like to avoid forcing the software keyboard on stylus taps.
|
|
28
|
+
function isTouchLikePointerType(pointerType: string) {
|
|
29
|
+
return pointerType === 'touch' || pointerType === 'pen'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UseNumberFieldButtonParameters {
|
|
33
|
+
isIncrement: boolean
|
|
34
|
+
inputRef: Ref<HTMLInputElement | null>
|
|
35
|
+
inputValue: () => string
|
|
36
|
+
disabled: () => boolean
|
|
37
|
+
readOnly: () => boolean
|
|
38
|
+
id: () => string | undefined
|
|
39
|
+
setValue: (value: number | null, details: NumberFieldRootChangeEventDetails) => boolean
|
|
40
|
+
getStepAmount: (event?: EventWithOptionalKeyState) => number | undefined
|
|
41
|
+
incrementValue: (amount: number, params: IncrementValueParameters) => boolean
|
|
42
|
+
allowInputSyncRef: Ref<boolean>
|
|
43
|
+
formatOptionsRef: ComputedRef<Intl.NumberFormatOptions | undefined>
|
|
44
|
+
valueRef: Ref<number | null>
|
|
45
|
+
locale: () => Intl.LocalesArgument
|
|
46
|
+
lastChangedValueRef: Ref<number | null>
|
|
47
|
+
onValueCommitted: (value: number | null, eventDetails: NumberFieldRootCommitEventDetails) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useNumberFieldButton(
|
|
51
|
+
params: UseNumberFieldButtonParameters,
|
|
52
|
+
): ComputedRef<Record<string, any>> {
|
|
53
|
+
const {
|
|
54
|
+
allowInputSyncRef,
|
|
55
|
+
formatOptionsRef,
|
|
56
|
+
getStepAmount,
|
|
57
|
+
incrementValue,
|
|
58
|
+
inputRef,
|
|
59
|
+
inputValue,
|
|
60
|
+
isIncrement,
|
|
61
|
+
locale,
|
|
62
|
+
setValue,
|
|
63
|
+
valueRef,
|
|
64
|
+
lastChangedValueRef,
|
|
65
|
+
onValueCommitted,
|
|
66
|
+
} = params
|
|
67
|
+
|
|
68
|
+
const disabled = () => params.disabled()
|
|
69
|
+
const readOnly = () => params.readOnly()
|
|
70
|
+
|
|
71
|
+
const pressReason: NumberFieldRootChangeEventReason = isIncrement
|
|
72
|
+
? REASONS.incrementPress
|
|
73
|
+
: REASONS.decrementPress
|
|
74
|
+
|
|
75
|
+
function commitValue(nativeEvent: MouseEvent) {
|
|
76
|
+
allowInputSyncRef.value = true
|
|
77
|
+
|
|
78
|
+
// The input may be dirty but not yet blurred, so the value won't have been committed.
|
|
79
|
+
const parsedValue = parseNumber(inputValue(), locale(), formatOptionsRef.value)
|
|
80
|
+
|
|
81
|
+
if (parsedValue !== null) {
|
|
82
|
+
// The increment value function needs to know the current input value to increment it
|
|
83
|
+
// correctly.
|
|
84
|
+
valueRef.value = parsedValue
|
|
85
|
+
setValue(
|
|
86
|
+
parsedValue,
|
|
87
|
+
createChangeEventDetails<NumberFieldRootChangeEventReason, { direction?: Direction }>(
|
|
88
|
+
pressReason,
|
|
89
|
+
nativeEvent,
|
|
90
|
+
undefined,
|
|
91
|
+
{ direction: isIncrement ? 1 : -1 },
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { pointerHandlers, shouldSkipClick } = usePressAndHold({
|
|
98
|
+
disabled: () => disabled() || readOnly(),
|
|
99
|
+
elementRef: inputRef,
|
|
100
|
+
tickDelay: CHANGE_VALUE_TICK_DELAY,
|
|
101
|
+
startDelay: START_AUTO_CHANGE_DELAY,
|
|
102
|
+
scrollDistance: SCROLLING_POINTER_MOVE_DISTANCE,
|
|
103
|
+
tick(triggerEvent) {
|
|
104
|
+
const amount = getStepAmount(triggerEvent as EventWithOptionalKeyState) ?? DEFAULT_STEP
|
|
105
|
+
return incrementValue(amount, {
|
|
106
|
+
direction: isIncrement ? 1 : -1,
|
|
107
|
+
event: triggerEvent,
|
|
108
|
+
reason: pressReason,
|
|
109
|
+
})
|
|
110
|
+
},
|
|
111
|
+
onStop(nativeEvent: PointerEvent) {
|
|
112
|
+
const committed = lastChangedValueRef.value ?? valueRef.value
|
|
113
|
+
onValueCommitted(committed, createGenericEventDetails(pressReason, nativeEvent))
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return computed<Record<string, any>>(() => ({
|
|
118
|
+
'disabled': disabled(),
|
|
119
|
+
'aria-readonly': readOnly() || undefined,
|
|
120
|
+
'aria-label': isIncrement ? 'Increase' : 'Decrease',
|
|
121
|
+
'aria-controls': params.id(),
|
|
122
|
+
// Keyboard users shouldn't have access to the buttons, since they can use the input element
|
|
123
|
+
// to change the value. On the other hand, `aria-hidden` is not applied because touch screen
|
|
124
|
+
// readers should be able to use the buttons.
|
|
125
|
+
'tabindex': -1,
|
|
126
|
+
'style': {
|
|
127
|
+
WebkitUserSelect: 'none',
|
|
128
|
+
userSelect: 'none',
|
|
129
|
+
},
|
|
130
|
+
...pointerHandlers,
|
|
131
|
+
onClick(event: MouseEvent) {
|
|
132
|
+
const isDisabled = disabled() || readOnly()
|
|
133
|
+
if (event.defaultPrevented || isDisabled || shouldSkipClick(event)) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
commitValue(event)
|
|
138
|
+
|
|
139
|
+
const amount = getStepAmount(event as EventWithOptionalKeyState) ?? DEFAULT_STEP
|
|
140
|
+
|
|
141
|
+
const prev = valueRef.value
|
|
142
|
+
|
|
143
|
+
incrementValue(amount, {
|
|
144
|
+
direction: isIncrement ? 1 : -1,
|
|
145
|
+
event,
|
|
146
|
+
reason: pressReason,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const committed = lastChangedValueRef.value ?? valueRef.value
|
|
150
|
+
if (committed !== prev) {
|
|
151
|
+
onValueCommitted(committed, createGenericEventDetails(pressReason, event))
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
onPointerdown(event: PointerEvent) {
|
|
155
|
+
const isMainButton = !event.button || event.button === 0
|
|
156
|
+
if (event.defaultPrevented || readOnly() || !isMainButton || disabled()) {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Sync dirty input value before starting the hold sequence.
|
|
161
|
+
commitValue(event)
|
|
162
|
+
|
|
163
|
+
if (!isTouchLikePointerType(event.pointerType)) {
|
|
164
|
+
// Focus the input so the user can continue with keyboard interactions.
|
|
165
|
+
inputRef.value?.focus()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
pointerHandlers.onPointerdown(event)
|
|
169
|
+
},
|
|
170
|
+
}))
|
|
171
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { BaseUIComponentProps } from '../../utils/types'
|
|
3
|
+
import type { NumberFieldRootState } from '../root/NumberFieldRoot.vue'
|
|
4
|
+
import { computed, onMounted, provide, ref, useAttrs, watch } from 'vue'
|
|
5
|
+
import { getTarget } from '../../floating-ui-vue/utils'
|
|
6
|
+
import { mergeProps } from '../../merge-props/mergeProps'
|
|
7
|
+
import { createGenericEventDetails } from '../../utils/createBaseUIEventDetails'
|
|
8
|
+
import { isFirefox, isWebKit } from '../../utils/detectBrowser'
|
|
9
|
+
import { ownerDocument, ownerWindow } from '../../utils/owner'
|
|
10
|
+
import { REASONS } from '../../utils/reasons'
|
|
11
|
+
import { useRenderElement } from '../../utils/useRenderElement'
|
|
12
|
+
import { useTimeout } from '../../utils/useTimeout'
|
|
13
|
+
import { useNumberFieldRootContext } from '../root/NumberFieldRootContext'
|
|
14
|
+
import { DEFAULT_STEP } from '../utils/constants'
|
|
15
|
+
import { getViewportRect } from '../utils/getViewportRect'
|
|
16
|
+
import { stateAttributesMapping } from '../utils/stateAttributesMapping'
|
|
17
|
+
import { subscribeToVisualViewportResize } from '../utils/subscribeToVisualViewportResize'
|
|
18
|
+
import { numberFieldScrubAreaContextKey } from './NumberFieldScrubAreaContext'
|
|
19
|
+
|
|
20
|
+
export type NumberFieldScrubAreaState = NumberFieldRootState
|
|
21
|
+
|
|
22
|
+
export interface NumberFieldScrubAreaProps extends BaseUIComponentProps<NumberFieldScrubAreaState> {
|
|
23
|
+
/**
|
|
24
|
+
* Cursor movement direction in the scrub area.
|
|
25
|
+
* @default 'horizontal'
|
|
26
|
+
*/
|
|
27
|
+
direction?: 'horizontal' | 'vertical'
|
|
28
|
+
/**
|
|
29
|
+
* Determines how many pixels the cursor must move before the value changes.
|
|
30
|
+
* A higher value will make scrubbing less sensitive.
|
|
31
|
+
* @default 2
|
|
32
|
+
*/
|
|
33
|
+
pixelSensitivity?: number
|
|
34
|
+
/**
|
|
35
|
+
* If specified, determines the distance that the cursor may move from the center
|
|
36
|
+
* of the scrub area before it will loop back around.
|
|
37
|
+
*/
|
|
38
|
+
teleportDistance?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
defineOptions({
|
|
42
|
+
name: 'NumberFieldScrubArea',
|
|
43
|
+
inheritAttrs: false,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const props = withDefaults(defineProps<NumberFieldScrubAreaProps>(), {
|
|
47
|
+
as: 'span',
|
|
48
|
+
direction: 'horizontal',
|
|
49
|
+
pixelSensitivity: 2,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const attrs = useAttrs()
|
|
53
|
+
const attrsObject = attrs as Record<string, any>
|
|
54
|
+
|
|
55
|
+
const {
|
|
56
|
+
state,
|
|
57
|
+
setIsScrubbing: setRootScrubbing,
|
|
58
|
+
disabled,
|
|
59
|
+
readOnly,
|
|
60
|
+
inputRef,
|
|
61
|
+
incrementValue,
|
|
62
|
+
allowInputSyncRef,
|
|
63
|
+
getStepAmount,
|
|
64
|
+
onValueCommitted,
|
|
65
|
+
lastChangedValueRef,
|
|
66
|
+
valueRef,
|
|
67
|
+
} = useNumberFieldRootContext()
|
|
68
|
+
|
|
69
|
+
const direction = computed(() => props.direction)
|
|
70
|
+
const pixelSensitivity = computed(() => props.pixelSensitivity)
|
|
71
|
+
const teleportDistance = computed(() => props.teleportDistance)
|
|
72
|
+
|
|
73
|
+
const scrubAreaRef = ref<HTMLElement | null>(null)
|
|
74
|
+
const scrubAreaCursorRef = ref<HTMLElement | null>(null)
|
|
75
|
+
|
|
76
|
+
let isScrubbingInternal = false
|
|
77
|
+
let didMove = false
|
|
78
|
+
let pointerDownTarget: EventTarget | null = null
|
|
79
|
+
let virtualCursorCoords = { x: 0, y: 0 }
|
|
80
|
+
const visualScaleRef = ref(1)
|
|
81
|
+
|
|
82
|
+
const exitPointerLockTimeout = useTimeout()
|
|
83
|
+
|
|
84
|
+
const isTouchInput = ref(false)
|
|
85
|
+
const isPointerLockDenied = ref(false)
|
|
86
|
+
const isScrubbing = ref(false)
|
|
87
|
+
|
|
88
|
+
watch(
|
|
89
|
+
isScrubbing,
|
|
90
|
+
(scrubbing, _prev, onCleanup) => {
|
|
91
|
+
if (!scrubbing || !scrubAreaCursorRef.value) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const unsubscribe = subscribeToVisualViewportResize(scrubAreaCursorRef.value, visualScaleRef)
|
|
96
|
+
onCleanup(unsubscribe)
|
|
97
|
+
},
|
|
98
|
+
{ flush: 'post' },
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
function updateCursorTransform(x: number, y: number) {
|
|
102
|
+
if (scrubAreaCursorRef.value) {
|
|
103
|
+
scrubAreaCursorRef.value.style.transform = `translate3d(${x}px,${y}px,0) scale(${1 / visualScaleRef.value})`
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onScrub({ movementX, movementY }: PointerEvent) {
|
|
108
|
+
const virtualCursor = scrubAreaCursorRef.value
|
|
109
|
+
const scrubAreaEl = scrubAreaRef.value
|
|
110
|
+
|
|
111
|
+
if (!virtualCursor || !scrubAreaEl) {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const rect = getViewportRect(teleportDistance.value, scrubAreaEl)
|
|
116
|
+
|
|
117
|
+
const coords = virtualCursorCoords
|
|
118
|
+
const newCoords = {
|
|
119
|
+
x: Math.round(coords.x + movementX),
|
|
120
|
+
y: Math.round(coords.y + movementY),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cursorWidth = virtualCursor.offsetWidth
|
|
124
|
+
const cursorHeight = virtualCursor.offsetHeight
|
|
125
|
+
|
|
126
|
+
if (newCoords.x + cursorWidth / 2 < rect.x) {
|
|
127
|
+
newCoords.x = rect.width - cursorWidth / 2
|
|
128
|
+
}
|
|
129
|
+
else if (newCoords.x + cursorWidth / 2 > rect.width) {
|
|
130
|
+
newCoords.x = rect.x - cursorWidth / 2
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (newCoords.y + cursorHeight / 2 < rect.y) {
|
|
134
|
+
newCoords.y = rect.height - cursorHeight / 2
|
|
135
|
+
}
|
|
136
|
+
else if (newCoords.y + cursorHeight / 2 > rect.height) {
|
|
137
|
+
newCoords.y = rect.y - cursorHeight / 2
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
virtualCursorCoords = newCoords
|
|
141
|
+
|
|
142
|
+
updateCursorTransform(newCoords.x, newCoords.y)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onScrubbingChange(scrubbingValue: boolean, { clientX, clientY }: PointerEvent) {
|
|
146
|
+
isScrubbing.value = scrubbingValue
|
|
147
|
+
setRootScrubbing(scrubbingValue)
|
|
148
|
+
|
|
149
|
+
const virtualCursor = scrubAreaCursorRef.value
|
|
150
|
+
if (!virtualCursor || !scrubbingValue) {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const initialCoords = {
|
|
155
|
+
x: clientX - virtualCursor.offsetWidth / 2,
|
|
156
|
+
y: clientY - virtualCursor.offsetHeight / 2,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
virtualCursorCoords = initialCoords
|
|
160
|
+
|
|
161
|
+
updateCursorTransform(initialCoords.x, initialCoords.y)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Register global scrubbing listeners only while actively scrubbing.
|
|
165
|
+
watch(
|
|
166
|
+
[isScrubbing, disabled, readOnly],
|
|
167
|
+
(_value, _prev, onCleanup) => {
|
|
168
|
+
if (!inputRef.value || disabled.value || readOnly.value || !isScrubbing.value) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let cumulativeDelta = 0
|
|
173
|
+
|
|
174
|
+
function handleScrubPointerUp(event: PointerEvent) {
|
|
175
|
+
function handler() {
|
|
176
|
+
try {
|
|
177
|
+
ownerDocument(scrubAreaRef.value)?.exitPointerLock()
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Ignore errors.
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
isScrubbingInternal = false
|
|
184
|
+
onScrubbingChange(false, event)
|
|
185
|
+
onValueCommitted(
|
|
186
|
+
lastChangedValueRef.value ?? valueRef.value,
|
|
187
|
+
createGenericEventDetails(REASONS.scrub, event),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
// Manually dispatch a click event if no movement happened, since
|
|
191
|
+
// preventDefault on pointerdown prevents the browser click event.
|
|
192
|
+
const target = pointerDownTarget
|
|
193
|
+
const input = inputRef.value
|
|
194
|
+
if (!didMove && target != null && input) {
|
|
195
|
+
target.dispatchEvent(
|
|
196
|
+
new (ownerWindow(input).MouseEvent)('click', {
|
|
197
|
+
bubbles: true,
|
|
198
|
+
cancelable: true,
|
|
199
|
+
}),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
didMove = false
|
|
204
|
+
pointerDownTarget = null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (isFirefox) {
|
|
209
|
+
// Firefox needs a small delay here when soft-clicking as the pointer
|
|
210
|
+
// lock will not release otherwise.
|
|
211
|
+
exitPointerLockTimeout.start(20, handler)
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
handler()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function handleScrubPointerMove(event: PointerEvent) {
|
|
219
|
+
if (!isScrubbingInternal) {
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Prevent text selection.
|
|
224
|
+
event.preventDefault()
|
|
225
|
+
|
|
226
|
+
onScrub(event)
|
|
227
|
+
|
|
228
|
+
const { movementX, movementY } = event
|
|
229
|
+
|
|
230
|
+
cumulativeDelta += direction.value === 'vertical' ? movementY : movementX
|
|
231
|
+
|
|
232
|
+
if (Math.abs(cumulativeDelta) >= pixelSensitivity.value) {
|
|
233
|
+
cumulativeDelta = 0
|
|
234
|
+
didMove = true
|
|
235
|
+
const dValue = direction.value === 'vertical' ? -movementY : movementX
|
|
236
|
+
const stepAmount = getStepAmount(event) ?? DEFAULT_STEP
|
|
237
|
+
const rawAmount = dValue * stepAmount
|
|
238
|
+
|
|
239
|
+
if (rawAmount !== 0) {
|
|
240
|
+
allowInputSyncRef.value = true
|
|
241
|
+
incrementValue(Math.abs(rawAmount), {
|
|
242
|
+
direction: rawAmount >= 0 ? 1 : -1,
|
|
243
|
+
event,
|
|
244
|
+
reason: REASONS.scrub,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const win = ownerWindow(inputRef.value)
|
|
251
|
+
win.addEventListener('pointerup', handleScrubPointerUp, true)
|
|
252
|
+
win.addEventListener('pointermove', handleScrubPointerMove, true)
|
|
253
|
+
|
|
254
|
+
onCleanup(() => {
|
|
255
|
+
exitPointerLockTimeout.clear()
|
|
256
|
+
win.removeEventListener('pointerup', handleScrubPointerUp, true)
|
|
257
|
+
win.removeEventListener('pointermove', handleScrubPointerMove, true)
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
{ flush: 'post' },
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
// Prevent scrolling using touch input when scrubbing.
|
|
264
|
+
onMounted(() => {
|
|
265
|
+
const element = scrubAreaRef.value
|
|
266
|
+
if (!element || disabled.value || readOnly.value) {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handleTouchStart(event: TouchEvent) {
|
|
271
|
+
if (event.touches.length === 1) {
|
|
272
|
+
event.preventDefault()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
element.addEventListener('touchstart', handleTouchStart, { passive: false })
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
async function onPointerdown(event: PointerEvent) {
|
|
280
|
+
const isMainButton = !event.button || event.button === 0
|
|
281
|
+
if (event.defaultPrevented || readOnly.value || !isMainButton || disabled.value) {
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const isTouch = event.pointerType === 'touch'
|
|
286
|
+
isTouchInput.value = isTouch
|
|
287
|
+
|
|
288
|
+
if (event.pointerType === 'mouse') {
|
|
289
|
+
event.preventDefault()
|
|
290
|
+
inputRef.value?.focus()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
isScrubbingInternal = true
|
|
294
|
+
didMove = false
|
|
295
|
+
pointerDownTarget = getTarget(event)
|
|
296
|
+
onScrubbingChange(true, event)
|
|
297
|
+
|
|
298
|
+
// WebKit causes significant layout shift with the native message, so we can't use it.
|
|
299
|
+
if (!isTouch && !isWebKit) {
|
|
300
|
+
try {
|
|
301
|
+
// Avoid non-deterministic errors in testing environments.
|
|
302
|
+
await ownerDocument(scrubAreaRef.value)?.body.requestPointerLock()
|
|
303
|
+
isPointerLockDenied.value = false
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
isPointerLockDenied.value = true
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
if (isScrubbingInternal) {
|
|
310
|
+
onScrubbingChange(true, event)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const defaultProps = computed(() => ({
|
|
317
|
+
role: 'presentation',
|
|
318
|
+
style: {
|
|
319
|
+
touchAction: 'none',
|
|
320
|
+
WebkitUserSelect: 'none',
|
|
321
|
+
userSelect: 'none',
|
|
322
|
+
},
|
|
323
|
+
onPointerdown,
|
|
324
|
+
}))
|
|
325
|
+
|
|
326
|
+
const scrubAreaProps = computed(() => mergeProps(defaultProps.value, attrsObject))
|
|
327
|
+
|
|
328
|
+
provide(numberFieldScrubAreaContextKey, {
|
|
329
|
+
isScrubbing,
|
|
330
|
+
isTouchInput,
|
|
331
|
+
isPointerLockDenied,
|
|
332
|
+
scrubAreaCursorRef,
|
|
333
|
+
scrubAreaRef,
|
|
334
|
+
direction,
|
|
335
|
+
pixelSensitivity,
|
|
336
|
+
teleportDistance,
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const {
|
|
340
|
+
tag,
|
|
341
|
+
mergedProps,
|
|
342
|
+
renderless,
|
|
343
|
+
ref: renderRef,
|
|
344
|
+
} = useRenderElement({
|
|
345
|
+
componentProps: props,
|
|
346
|
+
state,
|
|
347
|
+
props: scrubAreaProps,
|
|
348
|
+
stateAttributesMapping,
|
|
349
|
+
defaultTagName: 'span',
|
|
350
|
+
ref: scrubAreaRef,
|
|
351
|
+
})
|
|
352
|
+
</script>
|
|
353
|
+
|
|
354
|
+
<template>
|
|
355
|
+
<slot v-if="renderless" :ref="renderRef" :props="mergedProps" :state="state" />
|
|
356
|
+
<component :is="tag" v-else :ref="renderRef" v-bind="mergedProps">
|
|
357
|
+
<slot :state="state" />
|
|
358
|
+
</component>
|
|
359
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ComputedRef, InjectionKey, Ref } from 'vue'
|
|
2
|
+
import { inject } from 'vue'
|
|
3
|
+
|
|
4
|
+
export interface NumberFieldScrubAreaContext {
|
|
5
|
+
isScrubbing: Readonly<Ref<boolean>>
|
|
6
|
+
isTouchInput: Readonly<Ref<boolean>>
|
|
7
|
+
isPointerLockDenied: Readonly<Ref<boolean>>
|
|
8
|
+
scrubAreaCursorRef: Ref<HTMLElement | null>
|
|
9
|
+
scrubAreaRef: Ref<HTMLElement | null>
|
|
10
|
+
direction: ComputedRef<'horizontal' | 'vertical'>
|
|
11
|
+
pixelSensitivity: ComputedRef<number>
|
|
12
|
+
teleportDistance: ComputedRef<number | undefined>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const numberFieldScrubAreaContextKey: InjectionKey<NumberFieldScrubAreaContext>
|
|
16
|
+
= Symbol('NumberFieldScrubAreaContext')
|
|
17
|
+
|
|
18
|
+
export function useNumberFieldScrubAreaContext() {
|
|
19
|
+
const context = inject(numberFieldScrubAreaContextKey, undefined)
|
|
20
|
+
if (context === undefined) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'Base UI Vue: NumberFieldScrubAreaContext is missing. NumberFieldScrubArea parts must be placed within <NumberFieldScrubArea>.',
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
return context
|
|
26
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ComponentPublicInstance } from 'vue'
|
|
3
|
+
import type { BaseUIComponentProps } from '../../utils/types'
|
|
4
|
+
import type { NumberFieldRootState } from '../root/NumberFieldRoot.vue'
|
|
5
|
+
import { computed, useAttrs } from 'vue'
|
|
6
|
+
import { mergeProps } from '../../merge-props/mergeProps'
|
|
7
|
+
import { isWebKit } from '../../utils/detectBrowser'
|
|
8
|
+
import { useRenderElement } from '../../utils/useRenderElement'
|
|
9
|
+
import { useNumberFieldRootContext } from '../root/NumberFieldRootContext'
|
|
10
|
+
import { useNumberFieldScrubAreaContext } from '../scrub-area/NumberFieldScrubAreaContext'
|
|
11
|
+
import { stateAttributesMapping } from '../utils/stateAttributesMapping'
|
|
12
|
+
|
|
13
|
+
export type NumberFieldScrubAreaCursorState = NumberFieldRootState
|
|
14
|
+
|
|
15
|
+
export interface NumberFieldScrubAreaCursorProps
|
|
16
|
+
extends BaseUIComponentProps<NumberFieldScrubAreaCursorState> {}
|
|
17
|
+
|
|
18
|
+
defineOptions({
|
|
19
|
+
name: 'NumberFieldScrubAreaCursor',
|
|
20
|
+
inheritAttrs: false,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const props = defineProps<NumberFieldScrubAreaCursorProps>()
|
|
24
|
+
|
|
25
|
+
const attrs = useAttrs()
|
|
26
|
+
const attrsObject = attrs as Record<string, any>
|
|
27
|
+
|
|
28
|
+
const { state } = useNumberFieldRootContext()
|
|
29
|
+
const { isScrubbing, isTouchInput, isPointerLockDenied, scrubAreaCursorRef }
|
|
30
|
+
= useNumberFieldScrubAreaContext()
|
|
31
|
+
|
|
32
|
+
const shouldRender = computed(
|
|
33
|
+
() => isScrubbing.value && !isWebKit && !isTouchInput.value && !isPointerLockDenied.value,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
function setCursorRef(el: Element | ComponentPublicInstance | null) {
|
|
37
|
+
scrubAreaCursorRef.value = el as HTMLElement | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cursorProps = computed(() => mergeProps(
|
|
41
|
+
{
|
|
42
|
+
role: 'presentation',
|
|
43
|
+
style: {
|
|
44
|
+
position: 'fixed',
|
|
45
|
+
top: 0,
|
|
46
|
+
left: 0,
|
|
47
|
+
pointerEvents: 'none',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
attrsObject,
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
const {
|
|
54
|
+
tag,
|
|
55
|
+
mergedProps,
|
|
56
|
+
renderless,
|
|
57
|
+
ref: renderRef,
|
|
58
|
+
} = useRenderElement({
|
|
59
|
+
componentProps: props,
|
|
60
|
+
state,
|
|
61
|
+
props: cursorProps,
|
|
62
|
+
stateAttributesMapping,
|
|
63
|
+
defaultTagName: 'span',
|
|
64
|
+
ref: setCursorRef,
|
|
65
|
+
})
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<Teleport v-if="shouldRender" to="body">
|
|
70
|
+
<slot v-if="renderless" :ref="renderRef" :props="mergedProps" :state="state" />
|
|
71
|
+
<component :is="tag" v-else :ref="renderRef" v-bind="mergedProps">
|
|
72
|
+
<slot :state="state" />
|
|
73
|
+
</component>
|
|
74
|
+
</Teleport>
|
|
75
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ownerWindow } from '../../utils/owner'
|
|
2
|
+
|
|
3
|
+
// Calculates the viewport rect for the virtual cursor.
|
|
4
|
+
export function getViewportRect(teleportDistance: number | undefined, scrubAreaEl: HTMLElement) {
|
|
5
|
+
const win = ownerWindow(scrubAreaEl)
|
|
6
|
+
const rect = scrubAreaEl.getBoundingClientRect()
|
|
7
|
+
|
|
8
|
+
if (rect && teleportDistance != null) {
|
|
9
|
+
return {
|
|
10
|
+
x: rect.left - teleportDistance / 2,
|
|
11
|
+
y: rect.top - teleportDistance / 2,
|
|
12
|
+
width: rect.right + teleportDistance / 2,
|
|
13
|
+
height: rect.bottom + teleportDistance / 2,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const vV = win.visualViewport
|
|
18
|
+
|
|
19
|
+
if (vV) {
|
|
20
|
+
return {
|
|
21
|
+
x: vV.offsetLeft,
|
|
22
|
+
y: vV.offsetTop,
|
|
23
|
+
width: vV.offsetLeft + vV.width,
|
|
24
|
+
height: vV.offsetTop + vV.height,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
x: 0,
|
|
30
|
+
y: 0,
|
|
31
|
+
width: win.document.documentElement.clientWidth,
|
|
32
|
+
height: win.document.documentElement.clientHeight,
|
|
33
|
+
}
|
|
34
|
+
}
|