base-ui-vue 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/button/ToolbarButton.cjs +6 -0
  2. package/dist/button/ToolbarButton.js +1 -1
  3. package/dist/control/SliderControl.js +2 -2
  4. package/dist/decrement/NumberFieldDecrement.cjs +861 -0
  5. package/dist/decrement/NumberFieldDecrement.cjs.map +1 -0
  6. package/dist/decrement/NumberFieldDecrement.js +700 -0
  7. package/dist/decrement/NumberFieldDecrement.js.map +1 -0
  8. package/dist/fallback/AvatarFallback.cjs +2 -46
  9. package/dist/fallback/AvatarFallback.cjs.map +1 -1
  10. package/dist/fallback/AvatarFallback.js +3 -41
  11. package/dist/fallback/AvatarFallback.js.map +1 -1
  12. package/dist/group/NumberFieldGroup.cjs +72 -0
  13. package/dist/group/NumberFieldGroup.cjs.map +1 -0
  14. package/dist/group/NumberFieldGroup.js +67 -0
  15. package/dist/group/NumberFieldGroup.js.map +1 -0
  16. package/dist/increment/NumberFieldIncrement.cjs +112 -0
  17. package/dist/increment/NumberFieldIncrement.cjs.map +1 -0
  18. package/dist/increment/NumberFieldIncrement.js +107 -0
  19. package/dist/increment/NumberFieldIncrement.js.map +1 -0
  20. package/dist/index.cjs +20 -1
  21. package/dist/index.d.cts +1387 -771
  22. package/dist/index.d.cts.map +1 -1
  23. package/dist/index.d.ts +1387 -771
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -3
  26. package/dist/index2.cjs +4292 -2479
  27. package/dist/index2.cjs.map +1 -1
  28. package/dist/index2.js +4155 -2408
  29. package/dist/index2.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/index.ts +3 -1
  32. package/src/number-field/decrement/NumberFieldDecrement.vue +109 -0
  33. package/src/number-field/group/NumberFieldGroup.vue +47 -0
  34. package/src/number-field/increment/NumberFieldIncrement.vue +109 -0
  35. package/src/number-field/index.ts +42 -0
  36. package/src/number-field/input/NumberFieldInput.vue +455 -0
  37. package/src/number-field/root/NumberFieldRoot.vue +626 -0
  38. package/src/number-field/root/NumberFieldRootContext.ts +94 -0
  39. package/src/number-field/root/useNumberFieldButton.ts +171 -0
  40. package/src/number-field/scrub-area/NumberFieldScrubArea.vue +359 -0
  41. package/src/number-field/scrub-area/NumberFieldScrubAreaContext.ts +26 -0
  42. package/src/number-field/scrub-area-cursor/NumberFieldScrubAreaCursor.vue +75 -0
  43. package/src/number-field/utils/constants.ts +4 -0
  44. package/src/number-field/utils/getViewportRect.ts +34 -0
  45. package/src/number-field/utils/parse.ts +248 -0
  46. package/src/number-field/utils/stateAttributesMapping.ts +9 -0
  47. package/src/number-field/utils/subscribeToVisualViewportResize.ts +27 -0
  48. package/src/number-field/utils/types.ts +24 -0
  49. package/src/number-field/utils/validate.ts +120 -0
  50. package/src/otp-field/index.ts +22 -0
  51. package/src/otp-field/input/OtpFieldInput.vue +336 -0
  52. package/src/otp-field/root/OtpFieldRoot.vue +583 -0
  53. package/src/otp-field/root/OtpFieldRootContext.ts +81 -0
  54. package/src/otp-field/utils/otp.ts +135 -0
  55. package/src/otp-field/utils/stateAttributesMapping.ts +16 -0
  56. package/src/utils/detectBrowser.ts +15 -0
  57. package/src/utils/formatNumber.ts +35 -2
  58. package/src/utils/useInterval.ts +45 -0
  59. package/src/utils/usePressAndHold.ts +260 -0
  60. package/src/utils/useValueChanged.ts +21 -0
@@ -0,0 +1,135 @@
1
+ interface OTPValidationConfig {
2
+ slotPattern: string
3
+ getRootPattern: (length: number) => string
4
+ regexp: RegExp
5
+ inputMode: 'numeric' | 'text'
6
+ }
7
+
8
+ export type OtpValidationType = 'numeric' | 'alpha' | 'alphanumeric' | 'none'
9
+
10
+ const OTP_VALIDATION_CONFIG: Record<Exclude<OtpValidationType, 'none'>, OTPValidationConfig> = {
11
+ numeric: {
12
+ slotPattern: '\\d{1}',
13
+ getRootPattern: length => `\\d{${length}}`,
14
+ regexp: /\D/g,
15
+ inputMode: 'numeric',
16
+ },
17
+ alpha: {
18
+ slotPattern: '[a-z]{1}',
19
+ getRootPattern: length => `[a-z]{${length}}`,
20
+ regexp: /[^a-z]/gi,
21
+ inputMode: 'text',
22
+ },
23
+ alphanumeric: {
24
+ slotPattern: '[a-z0-9]{1}',
25
+ getRootPattern: length => `[a-z0-9]{${length}}`,
26
+ regexp: /[^a-z0-9]/gi,
27
+ inputMode: 'text',
28
+ },
29
+ }
30
+
31
+ export function getOTPValidationConfig(validationType: OtpValidationType) {
32
+ if (validationType === 'none') {
33
+ return null
34
+ }
35
+
36
+ return OTP_VALIDATION_CONFIG[validationType]
37
+ }
38
+
39
+ export function stripOTPWhitespace(value: string | null | undefined) {
40
+ return (value ?? '').replace(/\s/g, '')
41
+ }
42
+
43
+ function applyOTPValidation(value: string, validation: OTPValidationConfig | null) {
44
+ return validation ? value.replace(validation.regexp, '') : value
45
+ }
46
+
47
+ /**
48
+ * Normalizes user-entered OTP text by stripping whitespace, applying validation and custom
49
+ * normalization, and clamping the final value to the configured slot count.
50
+ */
51
+ export function normalizeOTPValueWithDetails(
52
+ value: string | null | undefined,
53
+ length: number,
54
+ validationType: OtpValidationType,
55
+ normalizeValue?: ((value: string) => string) | undefined,
56
+ ): readonly [value: string, didRejectCharacters: boolean] {
57
+ const strippedValue = stripOTPWhitespace(value)
58
+ const validation = getOTPValidationConfig(validationType)
59
+ let normalizedValue = applyOTPValidation(strippedValue, validation)
60
+ let didRejectCharacters = strippedValue.length > normalizedValue.length
61
+
62
+ if (normalizeValue) {
63
+ const customNormalizedValue = normalizeValue(normalizedValue)
64
+ didRejectCharacters ||= normalizedValue.length > customNormalizedValue.length
65
+ normalizedValue = applyOTPValidation(customNormalizedValue, validation)
66
+ didRejectCharacters ||= customNormalizedValue.length > normalizedValue.length
67
+ }
68
+
69
+ // Slice by Unicode code points so multi-byte characters do not split across OTP slots.
70
+ const maxLength = length < 0 ? 0 : length
71
+ const normalizedCharacters = Array.from(normalizedValue)
72
+
73
+ return [
74
+ normalizedCharacters.slice(0, maxLength).join(''),
75
+ didRejectCharacters || normalizedCharacters.length > maxLength,
76
+ ]
77
+ }
78
+
79
+ export function normalizeOTPValue(
80
+ value: string | null | undefined,
81
+ length: number,
82
+ validationType: OtpValidationType,
83
+ normalizeValue?: ((value: string) => string) | undefined,
84
+ ) {
85
+ return normalizeOTPValueWithDetails(value, length, validationType, normalizeValue)[0]
86
+ }
87
+
88
+ export function getOTPCharacters(value: string | null | undefined) {
89
+ return Array.from(value ?? '')
90
+ }
91
+
92
+ export function getOTPValueLength(value: string | null | undefined) {
93
+ return getOTPCharacters(value).length
94
+ }
95
+
96
+ export function getOTPCharacter(value: string | null | undefined, index: number) {
97
+ return getOTPCharacters(value)[index] ?? ''
98
+ }
99
+
100
+ /**
101
+ * Replaces characters starting at the provided slot index, then re-normalizes the final OTP value
102
+ * so paste and multi-character edits stay contiguous and length-bounded.
103
+ */
104
+ export function replaceOTPValue(
105
+ currentValue: string,
106
+ index: number,
107
+ nextValue: string,
108
+ length: number,
109
+ validationType: OtpValidationType,
110
+ normalizeValue?: ((value: string) => string) | undefined,
111
+ ) {
112
+ const normalizedValue = normalizeOTPValue(nextValue, length, validationType, normalizeValue)
113
+ const characters = getOTPCharacters(currentValue)
114
+ const replacementLength = getOTPValueLength(normalizedValue)
115
+ const prefix = characters.slice(0, index).join('')
116
+ const suffix = characters.slice(index + replacementLength).join('')
117
+
118
+ return normalizeOTPValue(
119
+ `${prefix}${normalizedValue}${suffix}`,
120
+ length,
121
+ validationType,
122
+ normalizeValue,
123
+ )
124
+ }
125
+
126
+ export function removeOTPCharacter(currentValue: string, index: number) {
127
+ const characters = getOTPCharacters(currentValue)
128
+
129
+ if (index < 0 || index >= characters.length) {
130
+ return currentValue
131
+ }
132
+
133
+ characters.splice(index, 1)
134
+ return characters.join('')
135
+ }
@@ -0,0 +1,16 @@
1
+ import type { StateAttributesMapping } from '../../utils/getStateAttributesProps'
2
+ import type { OtpFieldInputState } from '../input/OtpFieldInput.vue'
3
+ import type { OtpFieldRootState } from '../root/OtpFieldRoot.vue'
4
+ import { fieldValidityMapping } from '../../field/utils/constants'
5
+
6
+ export const rootStateAttributesMapping: StateAttributesMapping<OtpFieldRootState> = {
7
+ value: () => null,
8
+ length: () => null,
9
+ ...fieldValidityMapping,
10
+ }
11
+
12
+ export const inputStateAttributesMapping: StateAttributesMapping<OtpFieldInputState> = {
13
+ value: () => null,
14
+ index: () => null,
15
+ ...fieldValidityMapping,
16
+ }
@@ -0,0 +1,15 @@
1
+ const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
2
+ const platform = typeof navigator !== 'undefined' ? navigator.platform : ''
3
+ const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints : 0
4
+
5
+ export const isWebKit
6
+ = typeof CSS !== 'undefined' && typeof CSS.supports === 'function'
7
+ ? CSS.supports('-webkit-backdrop-filter:none') && !/chrome|android/i.test(ua)
8
+ : /\b(?:iphone|ipad|ipod)\b/i.test(ua)
9
+
10
+ export const isFirefox = /firefox/i.test(ua)
11
+
12
+ export const isIOS
13
+ = /\b(?:iphone|ipad|ipod)\b/i.test(ua)
14
+ // iPadOS reports as "MacIntel" but exposes touch points.
15
+ || (platform === 'MacIntel' && maxTouchPoints > 1)
@@ -1,9 +1,42 @@
1
+ const formatterCache = new Map<string, Intl.NumberFormat>()
2
+
3
+ /**
4
+ * Returns a memoized `Intl.NumberFormat` for the given locale/options.
5
+ */
6
+ export function getFormatter(locale?: Intl.LocalesArgument, options?: Intl.NumberFormatOptions) {
7
+ const optionsString = JSON.stringify({ locale, options })
8
+ const cachedFormatter = formatterCache.get(optionsString)
9
+
10
+ if (cachedFormatter) {
11
+ return cachedFormatter
12
+ }
13
+
14
+ const formatter = new Intl.NumberFormat(locale, options)
15
+ formatterCache.set(optionsString, formatter)
16
+
17
+ return formatter
18
+ }
19
+
1
20
  export function formatNumber(
2
- value: number,
21
+ value: number | null,
22
+ locale?: Intl.LocalesArgument,
23
+ options?: Intl.NumberFormatOptions,
24
+ ) {
25
+ if (value == null) {
26
+ return ''
27
+ }
28
+ return getFormatter(locale, options).format(value)
29
+ }
30
+
31
+ export function formatNumberMaxPrecision(
32
+ value: number | null,
3
33
  locale?: Intl.LocalesArgument,
4
34
  options?: Intl.NumberFormatOptions,
5
35
  ) {
6
- return new Intl.NumberFormat(locale, options).format(value)
36
+ return formatNumber(value, locale, {
37
+ ...options,
38
+ maximumFractionDigits: 20,
39
+ })
7
40
  }
8
41
 
9
42
  /**
@@ -0,0 +1,45 @@
1
+ import { onUnmounted } from 'vue'
2
+
3
+ type IntervalId = number
4
+
5
+ const EMPTY = 0 as IntervalId
6
+
7
+ export class Interval {
8
+ currentId: IntervalId = EMPTY
9
+
10
+ static create() {
11
+ return new Interval()
12
+ }
13
+
14
+ /**
15
+ * Repeatedly executes `fn` every `delay` ms, clearing any previously scheduled interval.
16
+ */
17
+ start(delay: number, fn: () => void) {
18
+ this.clear()
19
+ this.currentId = setInterval(fn, delay) as unknown as IntervalId
20
+ }
21
+
22
+ isStarted() {
23
+ return this.currentId !== EMPTY
24
+ }
25
+
26
+ clear = () => {
27
+ if (this.currentId !== EMPTY) {
28
+ clearInterval(this.currentId)
29
+ this.currentId = EMPTY
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * A `setInterval` with automatic cleanup on unmount.
36
+ */
37
+ export function useInterval() {
38
+ const interval = Interval.create()
39
+
40
+ onUnmounted(() => {
41
+ interval.clear()
42
+ })
43
+
44
+ return interval
45
+ }
@@ -0,0 +1,260 @@
1
+ import type { MaybeRefOrGetter, Ref } from 'vue'
2
+ import { onUnmounted, toValue } from 'vue'
3
+ import { ownerWindow } from './owner'
4
+ import { useInterval } from './useInterval'
5
+ import { useTimeout } from './useTimeout'
6
+
7
+ const DEFAULT_TICK_DELAY = 60
8
+ const DEFAULT_START_DELAY = 400
9
+ const DEFAULT_SCROLL_DISTANCE = 8
10
+ const TOUCH_TIMEOUT = 50
11
+ const MAX_POINTER_MOVES_AFTER_TOUCH = 3
12
+
13
+ // Treat pen as touch-like to avoid forcing the software keyboard on stylus taps.
14
+ function isTouchLikePointerType(pointerType: string) {
15
+ return pointerType === 'touch' || pointerType === 'pen'
16
+ }
17
+
18
+ export interface UsePressAndHoldParameters {
19
+ disabled: MaybeRefOrGetter<boolean>
20
+ readOnly?: MaybeRefOrGetter<boolean>
21
+ /**
22
+ * Called on each tick during a hold. Return `false` to stop the auto-change sequence.
23
+ */
24
+ tick: (triggerEvent?: Event) => boolean
25
+ /**
26
+ * Called when the hold ends via the global `pointerup` event.
27
+ */
28
+ onStop?: (nativeEvent: PointerEvent) => void
29
+ /**
30
+ * Interval between ticks once the hold is active.
31
+ * @default 60
32
+ */
33
+ tickDelay?: number
34
+ /**
35
+ * Delay before the repeating ticks start after the initial hold.
36
+ * @default 400
37
+ */
38
+ startDelay?: number
39
+ /**
40
+ * Pointer movement distance (px) that cancels the hold and is treated as scrolling.
41
+ * @default 8
42
+ */
43
+ scrollDistance?: number
44
+ /**
45
+ * Ref to the anchor element used to resolve `ownerWindow`.
46
+ */
47
+ elementRef: Ref<HTMLElement | null>
48
+ }
49
+
50
+ export interface PressAndHoldPointerHandlers {
51
+ onTouchstart: (event: TouchEvent) => void
52
+ onTouchend: (event: TouchEvent) => void
53
+ onPointerdown: (event: PointerEvent) => void
54
+ onPointerup: (event: PointerEvent) => void
55
+ onPointermove: (event: PointerEvent) => void
56
+ onMouseenter: (event: MouseEvent) => void
57
+ onMouseleave: (event: MouseEvent) => void
58
+ onMouseup: (event: MouseEvent) => void
59
+ }
60
+
61
+ export interface UsePressAndHoldReturnValue {
62
+ pointerHandlers: PressAndHoldPointerHandlers
63
+ /**
64
+ * Returns `true` if the `onClick` handler should be skipped.
65
+ */
66
+ shouldSkipClick: (event: MouseEvent) => boolean
67
+ }
68
+
69
+ /**
70
+ * Adds press-and-hold behavior to a button element.
71
+ * On pointer down, performs one action immediately, then after a delay starts
72
+ * continuous repeated actions at a fixed interval. Handles mouse, touch, and pen inputs.
73
+ */
74
+ export function usePressAndHold(params: UsePressAndHoldParameters): UsePressAndHoldReturnValue {
75
+ const {
76
+ tick,
77
+ onStop,
78
+ tickDelay = DEFAULT_TICK_DELAY,
79
+ startDelay = DEFAULT_START_DELAY,
80
+ scrollDistance = DEFAULT_SCROLL_DISTANCE,
81
+ elementRef,
82
+ } = params
83
+
84
+ const isDisabled = () => toValue(params.disabled)
85
+ const isReadOnly = () => toValue(params.readOnly ?? false)
86
+
87
+ const startTickTimeout = useTimeout()
88
+ const tickInterval = useInterval()
89
+ const intentionalTouchCheckTimeout = useTimeout()
90
+
91
+ let isPressed = false
92
+ let movesAfterTouch = 0
93
+ let downCoords = { x: 0, y: 0 }
94
+ let isTouchingButton = false
95
+ let ignoreClick = false
96
+ let pointerType = ''
97
+ let unsubscribeFromGlobalContextMenu: () => void = () => {}
98
+
99
+ function stopAutoChange() {
100
+ intentionalTouchCheckTimeout.clear()
101
+ startTickTimeout.clear()
102
+ tickInterval.clear()
103
+ unsubscribeFromGlobalContextMenu()
104
+ unsubscribeFromGlobalContextMenu = () => {}
105
+ movesAfterTouch = 0
106
+ }
107
+
108
+ function startAutoChange(triggerNativeEvent?: Event) {
109
+ stopAutoChange()
110
+
111
+ const element = elementRef.value
112
+ if (!element) {
113
+ return
114
+ }
115
+
116
+ const win = ownerWindow(element)
117
+
118
+ function handleContextMenu(event: Event) {
119
+ event.preventDefault()
120
+ }
121
+
122
+ // A global context menu listener prevents the context menu from appearing when
123
+ // the touch is slightly outside of the element's hit area.
124
+ win.addEventListener('contextmenu', handleContextMenu)
125
+ unsubscribeFromGlobalContextMenu = () => {
126
+ win.removeEventListener('contextmenu', handleContextMenu)
127
+ }
128
+
129
+ function handlePointerUp(event: PointerEvent) {
130
+ isPressed = false
131
+ stopAutoChange()
132
+ onStop?.(event)
133
+ }
134
+ win.addEventListener('pointerup', handlePointerUp, { once: true })
135
+
136
+ if (!tick(triggerNativeEvent)) {
137
+ stopAutoChange()
138
+ return
139
+ }
140
+
141
+ startTickTimeout.start(startDelay, () => {
142
+ tickInterval.start(tickDelay, () => {
143
+ if (!tick(triggerNativeEvent)) {
144
+ stopAutoChange()
145
+ }
146
+ })
147
+ })
148
+ }
149
+
150
+ onUnmounted(() => stopAutoChange())
151
+
152
+ const pointerHandlers: PressAndHoldPointerHandlers = {
153
+ onTouchstart() {
154
+ isTouchingButton = true
155
+ },
156
+ onTouchend() {
157
+ isTouchingButton = false
158
+ },
159
+ onPointerdown(event) {
160
+ const isMainButton = !event.button || event.button === 0
161
+ if (event.defaultPrevented || !isMainButton || isDisabled() || isReadOnly()) {
162
+ return
163
+ }
164
+
165
+ pointerType = event.pointerType
166
+ ignoreClick = false
167
+ isPressed = true
168
+ downCoords = { x: event.clientX, y: event.clientY }
169
+
170
+ const isTouchPointer = isTouchLikePointerType(event.pointerType)
171
+
172
+ if (!isTouchPointer) {
173
+ event.preventDefault()
174
+ startAutoChange(event)
175
+ }
176
+ else {
177
+ // Check if the pointerdown was intentional and not the result of a scroll or
178
+ // pinch-zoom. In that case, we don't want to start the auto-change sequence.
179
+ intentionalTouchCheckTimeout.start(TOUCH_TIMEOUT, () => {
180
+ const moves = movesAfterTouch
181
+ movesAfterTouch = 0
182
+ const stillPressed = isPressed
183
+ if (stillPressed && moves < MAX_POINTER_MOVES_AFTER_TOUCH) {
184
+ startAutoChange(event)
185
+ ignoreClick = true
186
+ }
187
+ else {
188
+ ignoreClick = false
189
+ stopAutoChange()
190
+ }
191
+ })
192
+ }
193
+ },
194
+ onPointerup(event) {
195
+ if (isTouchLikePointerType(event.pointerType)) {
196
+ isPressed = false
197
+ }
198
+ },
199
+ onPointermove(event) {
200
+ if (
201
+ isDisabled()
202
+ || isReadOnly()
203
+ || !isTouchLikePointerType(event.pointerType)
204
+ || !isPressed
205
+ ) {
206
+ return
207
+ }
208
+
209
+ movesAfterTouch += 1
210
+
211
+ const { x, y } = downCoords
212
+ const dx = x - event.clientX
213
+ const dy = y - event.clientY
214
+
215
+ if (dx ** 2 + dy ** 2 > scrollDistance ** 2) {
216
+ stopAutoChange()
217
+ }
218
+ },
219
+ onMouseenter(event) {
220
+ if (
221
+ event.defaultPrevented
222
+ || isDisabled()
223
+ || isReadOnly()
224
+ || !isPressed
225
+ || isTouchingButton
226
+ || isTouchLikePointerType(pointerType)
227
+ ) {
228
+ return
229
+ }
230
+
231
+ startAutoChange(event)
232
+ },
233
+ onMouseleave() {
234
+ if (isTouchingButton) {
235
+ return
236
+ }
237
+
238
+ stopAutoChange()
239
+ },
240
+ onMouseup() {
241
+ if (isTouchingButton) {
242
+ return
243
+ }
244
+
245
+ stopAutoChange()
246
+ },
247
+ }
248
+
249
+ function shouldSkipClick(event: MouseEvent): boolean {
250
+ if (event.defaultPrevented) {
251
+ return true
252
+ }
253
+ if (isTouchLikePointerType(pointerType)) {
254
+ return ignoreClick
255
+ }
256
+ return event.detail !== 0
257
+ }
258
+
259
+ return { pointerHandlers, shouldSkipClick }
260
+ }
@@ -0,0 +1,21 @@
1
+ import type { WatchSource } from 'vue'
2
+ import { watch } from 'vue'
3
+
4
+ /**
5
+ * Runs `onChange` whenever the watched value changes, passing the previous value.
6
+ *
7
+ * Mirrors the React `useValueChanged` helper: the callback fires after the DOM has
8
+ * been updated (`flush: 'post'`), which makes it safe to read or move focus inside it.
9
+ */
10
+ export function useValueChanged<T>(
11
+ source: WatchSource<T>,
12
+ onChange: (previousValue: T) => void,
13
+ ): void {
14
+ watch(
15
+ source,
16
+ (_newValue, previousValue) => {
17
+ onChange(previousValue)
18
+ },
19
+ { flush: 'post' },
20
+ )
21
+ }