@kushagradhawan/kookie-ui 0.1.104 → 0.1.107

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.
@@ -5,11 +5,7 @@ import * as ReactDOM from 'react-dom';
5
5
  import classNames from 'classnames';
6
6
  import { composeRefs } from 'radix-ui/internal';
7
7
 
8
- import {
9
- textFieldRootPropDefs,
10
- textFieldSlotPropDefs,
11
- type TextFieldSlotScrubProps,
12
- } from './text-field.props.js';
8
+ import { textFieldRootPropDefs, textFieldSlotPropDefs, type TextFieldSlotScrubProps } from './text-field.props.js';
13
9
  import { extractProps } from '../helpers/extract-props.js';
14
10
  import { marginPropDefs } from '../props/margin.props.js';
15
11
 
@@ -22,363 +18,398 @@ type TextFieldRootElement = React.ElementRef<'input'>;
22
18
  type TextFieldRootOwnProps = GetPropDefTypes<typeof textFieldRootPropDefs> & {
23
19
  defaultValue?: string | number;
24
20
  value?: string | number;
25
- type?:
26
- | 'date'
27
- | 'datetime-local'
28
- | 'email'
29
- | 'hidden'
30
- | 'month'
31
- | 'number'
32
- | 'password'
33
- | 'search'
34
- | 'tel'
35
- | 'text'
36
- | 'time'
37
- | 'url'
38
- | 'week';
21
+ type?: 'date' | 'datetime-local' | 'email' | 'hidden' | 'month' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'time' | 'url' | 'week';
39
22
  };
40
- type TextFieldInputProps = ComponentPropsWithout<
41
- 'input',
42
- NotInputTextualAttributes | 'color' | 'defaultValue' | 'size' | 'type' | 'value'
43
- >;
23
+ type TextFieldInputProps = ComponentPropsWithout<'input', NotInputTextualAttributes | 'color' | 'defaultValue' | 'size' | 'type' | 'value'>;
44
24
  interface TextFieldRootProps extends TextFieldInputProps, MarginProps, TextFieldRootOwnProps {}
45
- const TextFieldRoot = React.forwardRef<TextFieldRootElement, TextFieldRootProps>(
46
- (props, forwardedRef) => {
47
- const inputRef = React.useRef<HTMLInputElement>(null);
48
- const { children, className, color, radius, panelBackground, material, style, ...inputProps } =
49
- extractProps(props, textFieldRootPropDefs, marginPropDefs);
50
- const effectiveMaterial = material || panelBackground;
51
-
52
- // Generate unique IDs for accessibility
53
- const errorId = React.useId();
54
-
55
- // Determine invalid state
56
- const isInvalid = inputProps.error || inputProps.isInvalid;
57
-
58
- const { 'aria-describedby': ariaDescribedby, 'aria-labelledby': ariaLabelledby } = inputProps;
59
-
60
- // Build aria-describedby string
61
- const describedBy = React.useMemo(() => {
62
- const parts = [];
63
- if (inputProps.errorMessage) parts.push(errorId);
64
- if (ariaDescribedby) parts.push(ariaDescribedby);
65
- return parts.length > 0 ? parts.join(' ') : undefined;
66
- }, [inputProps.errorMessage, ariaDescribedby, errorId]);
67
-
68
- // Build aria attributes
69
- const ariaProps = React.useMemo(
70
- () => ({
71
- 'aria-invalid': isInvalid,
72
- 'aria-describedby': describedBy,
73
- 'aria-labelledby': ariaLabelledby,
74
- }),
75
- [isInvalid, describedBy, ariaLabelledby],
76
- );
77
-
78
- // Filter out our custom props to avoid DOM warnings
79
- const {
80
- error,
81
- errorMessage,
82
- isInvalid: _isInvalid,
83
- required,
84
- 'aria-describedby': _ariaDescribedby,
85
- 'aria-labelledby': _ariaLabelledby,
86
- ...nativeInputProps
87
- } = inputProps;
88
-
89
- // Memoized pointer event handler
90
- const handlePointerDown = React.useCallback((event: React.PointerEvent) => {
91
- const target = event.target as HTMLElement;
92
- if (target.closest('input, button, a')) return;
93
-
94
- const input = inputRef.current;
95
- if (!input) return;
96
-
97
- // Same selector as in the CSS to find the right slot
98
- const isRightSlot = target.closest(`
25
+ const TextFieldRoot = React.forwardRef<TextFieldRootElement, TextFieldRootProps>((props, forwardedRef) => {
26
+ const inputRef = React.useRef<HTMLInputElement>(null);
27
+ const { children, className, color, radius, panelBackground, material, style, ...inputProps } = extractProps(props, textFieldRootPropDefs, marginPropDefs);
28
+ const effectiveMaterial = material || panelBackground;
29
+
30
+ // Generate unique IDs for accessibility
31
+ const errorId = React.useId();
32
+
33
+ // Determine invalid state
34
+ const isInvalid = inputProps.error || inputProps.isInvalid;
35
+
36
+ const { 'aria-describedby': ariaDescribedby, 'aria-labelledby': ariaLabelledby } = inputProps;
37
+
38
+ // Build aria-describedby string
39
+ const describedBy = React.useMemo(() => {
40
+ const parts = [];
41
+ if (inputProps.errorMessage) parts.push(errorId);
42
+ if (ariaDescribedby) parts.push(ariaDescribedby);
43
+ return parts.length > 0 ? parts.join(' ') : undefined;
44
+ }, [inputProps.errorMessage, ariaDescribedby, errorId]);
45
+
46
+ // Build aria attributes
47
+ const ariaProps = React.useMemo(
48
+ () => ({
49
+ 'aria-invalid': isInvalid,
50
+ 'aria-describedby': describedBy,
51
+ 'aria-labelledby': ariaLabelledby,
52
+ }),
53
+ [isInvalid, describedBy, ariaLabelledby],
54
+ );
55
+
56
+ // Filter out our custom props to avoid DOM warnings
57
+ const { error, errorMessage, isInvalid: _isInvalid, required, 'aria-describedby': _ariaDescribedby, 'aria-labelledby': _ariaLabelledby, ...nativeInputProps } = inputProps;
58
+
59
+ // Memoized pointer event handler
60
+ const handlePointerDown = React.useCallback((event: React.PointerEvent) => {
61
+ const target = event.target as HTMLElement;
62
+ if (target.closest('input, button, a')) return;
63
+
64
+ const input = inputRef.current;
65
+ if (!input) return;
66
+
67
+ // Same selector as in the CSS to find the right slot
68
+ const isRightSlot = target.closest(`
99
69
  .rt-TextFieldSlot[data-side='right'],
100
70
  .rt-TextFieldSlot:not([data-side='right']) ~ .rt-TextFieldSlot:not([data-side='left'])
101
71
  `);
102
72
 
103
- const cursorPosition = isRightSlot ? input.value.length : 0;
104
-
105
- requestAnimationFrame(() => {
106
- // Only some input types support this, browsers will throw an error if not supported
107
- // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange#:~:text=Note%20that%20according,not%20support%20selection%22.
108
- try {
109
- input.setSelectionRange(cursorPosition, cursorPosition);
110
- } catch {}
111
- input.focus();
112
- });
113
- }, []);
114
-
115
- return (
116
- <div
117
- data-accent-color={color}
118
- data-radius={radius}
119
- data-panel-background={effectiveMaterial}
120
- data-material={effectiveMaterial}
121
- style={style}
122
- className={classNames('rt-TextFieldRoot', className, {
123
- 'rt-error': isInvalid,
124
- })}
125
- onPointerDown={handlePointerDown}
126
- >
127
- <input
128
- spellCheck="false"
129
- {...nativeInputProps}
130
- {...ariaProps}
131
- ref={composeRefs(inputRef, forwardedRef)}
132
- className="rt-reset rt-TextFieldInput"
133
- />
134
- {children}
135
- {inputProps.errorMessage && (
136
- <div id={errorId} className="rt-TextFieldErrorMessage" role="alert" aria-live="polite">
137
- {inputProps.errorMessage}
138
- </div>
139
- )}
140
- </div>
141
- );
142
- },
143
- );
73
+ const cursorPosition = isRightSlot ? input.value.length : 0;
74
+
75
+ requestAnimationFrame(() => {
76
+ // Only some input types support this, browsers will throw an error if not supported
77
+ // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange#:~:text=Note%20that%20according,not%20support%20selection%22.
78
+ try {
79
+ input.setSelectionRange(cursorPosition, cursorPosition);
80
+ } catch {}
81
+ input.focus();
82
+ });
83
+ }, []);
84
+
85
+ return (
86
+ <div
87
+ data-accent-color={color}
88
+ data-radius={radius}
89
+ data-panel-background={effectiveMaterial}
90
+ data-material={effectiveMaterial}
91
+ style={style}
92
+ className={classNames('rt-TextFieldRoot', className, {
93
+ 'rt-error': isInvalid,
94
+ })}
95
+ onPointerDown={handlePointerDown}
96
+ >
97
+ <input spellCheck="false" {...nativeInputProps} {...ariaProps} ref={composeRefs(inputRef, forwardedRef)} className="rt-reset rt-TextFieldInput" />
98
+ {children}
99
+ {inputProps.errorMessage && (
100
+ <div id={errorId} className="rt-TextFieldErrorMessage" role="alert" aria-live="polite">
101
+ {inputProps.errorMessage}
102
+ </div>
103
+ )}
104
+ </div>
105
+ );
106
+ });
144
107
  TextFieldRoot.displayName = 'TextField.Root';
145
108
 
146
109
  type TextFieldSlotElement = React.ElementRef<'div'>;
147
110
  type TextFieldSlotOwnProps = GetPropDefTypes<typeof textFieldSlotPropDefs> & TextFieldSlotScrubProps;
148
- interface TextFieldSlotProps
149
- extends ComponentPropsWithout<'div', RemovedProps>,
150
- TextFieldSlotOwnProps {}
151
- const TextFieldSlot = React.forwardRef<TextFieldSlotElement, TextFieldSlotProps>(
152
- (props, forwardedRef) => {
153
- // Extract scrub props first (not part of PropDef system)
154
- const {
155
- scrub,
156
- scrubValue,
157
- scrubStep = 1,
158
- scrubSensitivity = 1,
159
- scrubMin,
160
- scrubMax,
161
- scrubShiftMultiplier = 10,
162
- scrubAltMultiplier = 0.1,
163
- onScrub,
164
- ...restProps
165
- } = props;
166
-
167
- // Then extract styling props
168
- const { className, color, side, ...slotProps } = extractProps(restProps, textFieldSlotPropDefs);
169
-
170
- const slotRef = React.useRef<HTMLDivElement>(null);
171
- const [isScrubbing, setIsScrubbing] = React.useState(false);
172
- // Virtual cursor position - X wraps around viewport, Y follows mouse
173
- const [cursorPosition, setCursorPosition] = React.useState({ x: 0, y: 0 });
174
-
175
- // Track accumulated sub-step movement for precision
176
- const accumulatedMovement = React.useRef(0);
177
- // Track current value for clamping (initialized to scrubValue when scrubbing starts)
178
- const currentValue = React.useRef(0);
179
- // Track virtual X position for wrap-around (separate from rendered position)
180
- const virtualX = React.useRef(0);
181
-
182
- // Store scrub config in refs so document handlers can access latest values
183
- const scrubConfigRef = React.useRef({
184
- scrubValue,
185
- scrubStep,
186
- scrubSensitivity,
187
- scrubMin,
188
- scrubMax,
189
- scrubShiftMultiplier,
190
- scrubAltMultiplier,
191
- onScrub,
111
+ interface TextFieldSlotProps extends ComponentPropsWithout<'div', RemovedProps>, TextFieldSlotOwnProps {}
112
+
113
+ // Detect if we should use pointer capture instead of pointer lock
114
+ // Safari and Firefox show intrusive browser warnings with pointer lock
115
+ const usePointerCapture = (() => {
116
+ if (typeof navigator === 'undefined') return false;
117
+ const ua = navigator.userAgent;
118
+ const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
119
+ const isFirefox = /firefox/i.test(ua);
120
+ return isSafari || isFirefox;
121
+ })();
122
+
123
+ const TextFieldSlot = React.forwardRef<TextFieldSlotElement, TextFieldSlotProps>((props, forwardedRef) => {
124
+ // Extract scrub props first (not part of PropDef system)
125
+ const { scrub, scrubValue, scrubStep = 1, scrubSensitivity = 1, scrubMin, scrubMax, scrubShiftMultiplier = 10, scrubAltMultiplier = 0.1, onScrub, ...restProps } = props;
126
+
127
+ // Then extract styling props
128
+ const { className, color, side, ...slotProps } = extractProps(restProps, textFieldSlotPropDefs);
129
+
130
+ const slotRef = React.useRef<HTMLDivElement>(null);
131
+ const [isScrubbing, setIsScrubbing] = React.useState(false);
132
+ // Virtual cursor position - X wraps around viewport, Y follows mouse
133
+ const [cursorPosition, setCursorPosition] = React.useState({ x: 0, y: 0 });
134
+
135
+ // Track accumulated sub-step movement for precision
136
+ const accumulatedMovement = React.useRef(0);
137
+ // Track current value for clamping (initialized to scrubValue when scrubbing starts)
138
+ const currentValue = React.useRef(0);
139
+ // Track virtual X position for wrap-around (separate from rendered position)
140
+ const virtualX = React.useRef(0);
141
+ // Track pointer ID for capture mode
142
+ const pointerIdRef = React.useRef<number | null>(null);
143
+ // Track last X position for capture mode (movementX calculation)
144
+ const lastXRef = React.useRef(0);
145
+
146
+ // Store scrub config in refs so document handlers can access latest values
147
+ const scrubConfigRef = React.useRef({
148
+ scrubValue,
149
+ scrubStep,
150
+ scrubSensitivity,
151
+ scrubMin,
152
+ scrubMax,
153
+ scrubShiftMultiplier,
154
+ scrubAltMultiplier,
155
+ onScrub,
156
+ });
157
+ scrubConfigRef.current = {
158
+ scrubValue,
159
+ scrubStep,
160
+ scrubSensitivity,
161
+ scrubMin,
162
+ scrubMax,
163
+ scrubShiftMultiplier,
164
+ scrubAltMultiplier,
165
+ onScrub,
166
+ };
167
+
168
+ const handlePointerDown = React.useCallback(
169
+ (event: React.PointerEvent) => {
170
+ if (!scrub) return;
171
+
172
+ // Don't start scrubbing if clicking on interactive elements
173
+ const target = event.target as HTMLElement;
174
+ if (target.closest('input, button, a')) return;
175
+
176
+ event.preventDefault();
177
+ accumulatedMovement.current = 0;
178
+ // Initialize to current value so min/max clamping works correctly
179
+ currentValue.current = scrubValue ?? 0;
180
+
181
+ // Initialize virtual cursor at actual mouse position
182
+ virtualX.current = event.clientX;
183
+ setCursorPosition({ x: event.clientX, y: event.clientY });
184
+
185
+ const slot = slotRef.current;
186
+ if (!slot) return;
187
+
188
+ if (usePointerCapture) {
189
+ // Safari/Firefox: Use pointer capture (no browser warning)
190
+ pointerIdRef.current = event.pointerId;
191
+ lastXRef.current = event.clientX;
192
+ slot.setPointerCapture(event.pointerId);
193
+ setIsScrubbing(true);
194
+ } else {
195
+ // Chrome: Use pointer lock for infinite movement
196
+ slot.requestPointerLock();
197
+ }
198
+ },
199
+ [scrub, scrubValue],
200
+ );
201
+
202
+ // Handle pointer lock state changes (Chrome only)
203
+ React.useEffect(() => {
204
+ if (usePointerCapture) return;
205
+
206
+ const handlePointerLockChange = () => {
207
+ const isLocked = document.pointerLockElement === slotRef.current;
208
+ setIsScrubbing(isLocked);
209
+ if (!isLocked) {
210
+ // Fire callback with isChanging = false when scrubbing ends
211
+ scrubConfigRef.current.onScrub?.(0, false);
212
+ accumulatedMovement.current = 0;
213
+ currentValue.current = 0;
214
+ }
215
+ };
216
+
217
+ document.addEventListener('pointerlockchange', handlePointerLockChange);
218
+ return () => {
219
+ document.removeEventListener('pointerlockchange', handlePointerLockChange);
220
+ };
221
+ }, []);
222
+
223
+ // Shared scrub calculation logic
224
+ const processScrubMovement = React.useCallback((movementX: number, movementY: number, shiftKey: boolean, altKey: boolean) => {
225
+ const config = scrubConfigRef.current;
226
+ const viewportWidth = window.innerWidth;
227
+ const viewportHeight = window.innerHeight;
228
+
229
+ // Update virtual position with wrap-around at viewport edges
230
+ virtualX.current += movementX;
231
+ if (virtualX.current > viewportWidth) {
232
+ virtualX.current = virtualX.current % viewportWidth;
233
+ } else if (virtualX.current < 0) {
234
+ virtualX.current = viewportWidth + (virtualX.current % viewportWidth);
235
+ }
236
+
237
+ // Also track Y with wrap-around
238
+ setCursorPosition((prev) => {
239
+ let newY = prev.y + movementY;
240
+ if (newY > viewportHeight) {
241
+ newY = newY % viewportHeight;
242
+ } else if (newY < 0) {
243
+ newY = viewportHeight + (newY % viewportHeight);
244
+ }
245
+ return { x: virtualX.current, y: newY };
192
246
  });
193
- scrubConfigRef.current = {
194
- scrubValue,
195
- scrubStep,
196
- scrubSensitivity,
197
- scrubMin,
198
- scrubMax,
199
- scrubShiftMultiplier,
200
- scrubAltMultiplier,
201
- onScrub,
247
+
248
+ // Accumulate movement for sensitivity calculation
249
+ accumulatedMovement.current += movementX;
250
+
251
+ // Calculate how many steps we've moved
252
+ const stepsFromMovement = accumulatedMovement.current / config.scrubSensitivity;
253
+
254
+ if (Math.abs(stepsFromMovement) >= 1) {
255
+ // Determine modifier multiplier
256
+ let multiplier = 1;
257
+ if (shiftKey) {
258
+ multiplier = config.scrubShiftMultiplier;
259
+ } else if (altKey) {
260
+ multiplier = config.scrubAltMultiplier;
261
+ }
262
+
263
+ // Calculate delta
264
+ const wholeSteps = Math.trunc(stepsFromMovement);
265
+ const delta = wholeSteps * config.scrubStep * multiplier;
266
+
267
+ // Apply min/max clamping if bounds are set
268
+ let clampedDelta = delta;
269
+ if (config.scrubMin !== undefined || config.scrubMax !== undefined) {
270
+ const newValue = currentValue.current + delta;
271
+ const clampedValue = Math.max(config.scrubMin ?? -Infinity, Math.min(config.scrubMax ?? Infinity, newValue));
272
+ clampedDelta = clampedValue - currentValue.current;
273
+ currentValue.current = clampedValue;
274
+ } else {
275
+ currentValue.current += delta;
276
+ }
277
+
278
+ // Fire callback with clamped delta (isChanging = true during drag)
279
+ if (clampedDelta !== 0) {
280
+ config.onScrub?.(clampedDelta, true);
281
+ }
282
+
283
+ // Keep the fractional remainder for smooth sub-pixel accumulation
284
+ accumulatedMovement.current = (stepsFromMovement - wholeSteps) * config.scrubSensitivity;
285
+ }
286
+ }, []);
287
+
288
+ // End scrubbing helper
289
+ const endScrubbing = React.useCallback(() => {
290
+ scrubConfigRef.current.onScrub?.(0, false);
291
+ accumulatedMovement.current = 0;
292
+ currentValue.current = 0;
293
+ pointerIdRef.current = null;
294
+ setIsScrubbing(false);
295
+ }, []);
296
+
297
+ // Handle pointer capture mode events (Safari/Firefox)
298
+ const handlePointerMove = React.useCallback(
299
+ (event: React.PointerEvent) => {
300
+ if (!isScrubbing || !usePointerCapture) return;
301
+
302
+ const movementX = event.clientX - lastXRef.current;
303
+ lastXRef.current = event.clientX;
304
+
305
+ processScrubMovement(movementX, event.movementY, event.shiftKey, event.altKey);
306
+ },
307
+ [isScrubbing, processScrubMovement],
308
+ );
309
+
310
+ const handlePointerUp = React.useCallback(
311
+ (event: React.PointerEvent) => {
312
+ if (!usePointerCapture || pointerIdRef.current !== event.pointerId) return;
313
+
314
+ const slot = slotRef.current;
315
+ if (slot && pointerIdRef.current !== null) {
316
+ slot.releasePointerCapture(pointerIdRef.current);
317
+ }
318
+ endScrubbing();
319
+ },
320
+ [endScrubbing],
321
+ );
322
+
323
+ const handleLostPointerCapture = React.useCallback(() => {
324
+ if (usePointerCapture && isScrubbing) {
325
+ endScrubbing();
326
+ }
327
+ }, [isScrubbing, endScrubbing]);
328
+
329
+ // Attach document-level listeners for pointer lock mode (Chrome)
330
+ React.useEffect(() => {
331
+ if (!isScrubbing || usePointerCapture) return;
332
+
333
+ const handleMouseMove = (event: MouseEvent) => {
334
+ processScrubMovement(event.movementX, event.movementY, event.shiftKey, event.altKey);
202
335
  };
203
336
 
204
- const handlePointerDown = React.useCallback(
205
- (event: React.PointerEvent) => {
206
- if (!scrub) return;
337
+ const handleMouseUp = () => {
338
+ // Exit pointer lock to end scrubbing
339
+ document.exitPointerLock();
340
+ };
207
341
 
208
- // Don't start scrubbing if clicking on interactive elements
209
- const target = event.target as HTMLElement;
210
- if (target.closest('input, button, a')) return;
342
+ // Use mousemove for pointer lock (pointermove doesn't work well with pointer lock)
343
+ document.addEventListener('mousemove', handleMouseMove);
344
+ document.addEventListener('mouseup', handleMouseUp);
211
345
 
212
- event.preventDefault();
213
- accumulatedMovement.current = 0;
214
- // Initialize to current value so min/max clamping works correctly
215
- currentValue.current = scrubValue ?? 0;
216
-
217
- // Initialize virtual cursor at actual mouse position
218
- virtualX.current = event.clientX;
219
- setCursorPosition({ x: event.clientX, y: event.clientY });
220
-
221
- // Request pointer lock for infinite movement (cursor won't hit screen edges)
222
- const slot = slotRef.current;
223
- if (slot) {
224
- slot.requestPointerLock();
225
- }
226
- },
227
- [scrub, scrubValue],
228
- );
229
-
230
- // Handle pointer lock state changes
231
- React.useEffect(() => {
232
- const handlePointerLockChange = () => {
233
- const isLocked = document.pointerLockElement === slotRef.current;
234
- setIsScrubbing(isLocked);
235
- if (!isLocked) {
236
- // Fire callback with isChanging = false when scrubbing ends
237
- scrubConfigRef.current.onScrub?.(0, false);
238
- accumulatedMovement.current = 0;
239
- currentValue.current = 0;
240
- }
241
- };
242
-
243
- document.addEventListener('pointerlockchange', handlePointerLockChange);
244
- return () => {
245
- document.removeEventListener('pointerlockchange', handlePointerLockChange);
246
- };
247
- }, []);
248
-
249
- // Attach document-level listeners when scrubbing starts
250
- React.useEffect(() => {
251
- if (!isScrubbing) return;
252
-
253
- const handleMouseMove = (event: MouseEvent) => {
254
- const config = scrubConfigRef.current;
255
- const movementX = event.movementX;
256
- const movementY = event.movementY;
257
- const viewportWidth = window.innerWidth;
258
- const viewportHeight = window.innerHeight;
259
-
260
- // Update virtual position with wrap-around at viewport edges
261
- virtualX.current += movementX;
262
- if (virtualX.current > viewportWidth) {
263
- virtualX.current = virtualX.current % viewportWidth;
264
- } else if (virtualX.current < 0) {
265
- virtualX.current = viewportWidth + (virtualX.current % viewportWidth);
266
- }
267
-
268
- // Also track Y with wrap-around
269
- setCursorPosition((prev) => {
270
- let newY = prev.y + movementY;
271
- if (newY > viewportHeight) {
272
- newY = newY % viewportHeight;
273
- } else if (newY < 0) {
274
- newY = viewportHeight + (newY % viewportHeight);
275
- }
276
- return { x: virtualX.current, y: newY };
277
- });
278
-
279
- // Accumulate movement for sensitivity calculation
280
- accumulatedMovement.current += movementX;
281
-
282
- // Calculate how many steps we've moved
283
- const stepsFromMovement = accumulatedMovement.current / config.scrubSensitivity;
284
-
285
- if (Math.abs(stepsFromMovement) >= 1) {
286
- // Determine modifier multiplier
287
- let multiplier = 1;
288
- if (event.shiftKey) {
289
- multiplier = config.scrubShiftMultiplier;
290
- } else if (event.altKey) {
291
- multiplier = config.scrubAltMultiplier;
292
- }
293
-
294
- // Calculate delta
295
- const wholeSteps = Math.trunc(stepsFromMovement);
296
- const delta = wholeSteps * config.scrubStep * multiplier;
297
-
298
- // Apply min/max clamping if bounds are set
299
- let clampedDelta = delta;
300
- if (config.scrubMin !== undefined || config.scrubMax !== undefined) {
301
- const newValue = currentValue.current + delta;
302
- const clampedValue = Math.max(
303
- config.scrubMin ?? -Infinity,
304
- Math.min(config.scrubMax ?? Infinity, newValue),
305
- );
306
- clampedDelta = clampedValue - currentValue.current;
307
- currentValue.current = clampedValue;
308
- } else {
309
- currentValue.current += delta;
310
- }
311
-
312
- // Fire callback with clamped delta (isChanging = true during drag)
313
- if (clampedDelta !== 0) {
314
- config.onScrub?.(clampedDelta, true);
315
- }
316
-
317
- // Keep the fractional remainder for smooth sub-pixel accumulation
318
- accumulatedMovement.current = (stepsFromMovement - wholeSteps) * config.scrubSensitivity;
319
- }
320
- };
321
-
322
- const handleMouseUp = () => {
323
- // Exit pointer lock to end scrubbing
324
- document.exitPointerLock();
325
- };
326
-
327
- // Use mousemove for pointer lock (pointermove doesn't work well with pointer lock)
328
- document.addEventListener('mousemove', handleMouseMove);
329
- document.addEventListener('mouseup', handleMouseUp);
330
-
331
- // Disable text selection during scrubbing
332
- document.body.style.userSelect = 'none';
333
-
334
- return () => {
335
- document.removeEventListener('mousemove', handleMouseMove);
336
- document.removeEventListener('mouseup', handleMouseUp);
337
- document.body.style.userSelect = '';
338
- };
339
- }, [isScrubbing]);
340
-
341
- // Render virtual cursor via portal to body so it's not clipped
342
- // Using a data URI of ew-resize cursor for a native look
343
- const virtualCursor = isScrubbing
344
- ? ReactDOM.createPortal(
345
- <img
346
- className="rt-TextFieldSlotScrubCursor"
347
- src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='black' stroke='white' stroke-width='1' d='M0 12 L5 7 L5 10 L19 10 L19 7 L24 12 L19 17 L19 14 L5 14 L5 17 Z'/%3E%3C/svg%3E"
348
- alt=""
349
- style={{
350
- position: 'fixed',
351
- left: cursorPosition.x,
352
- top: cursorPosition.y,
353
- transform: 'translate(-50%, -50%)',
354
- pointerEvents: 'none',
355
- zIndex: 99999,
356
- width: 24,
357
- height: 24,
358
- }}
359
- aria-hidden="true"
360
- />,
361
- document.body,
362
- )
363
- : null;
364
-
365
- return (
366
- <div
367
- data-accent-color={color}
368
- data-side={side}
369
- data-scrub={scrub || undefined}
370
- data-scrubbing={isScrubbing || undefined}
371
- {...slotProps}
372
- ref={composeRefs(slotRef, forwardedRef)}
373
- className={classNames('rt-TextFieldSlot', className)}
374
- onPointerDown={scrub ? handlePointerDown : undefined}
375
- >
376
- {slotProps.children}
377
- {virtualCursor}
378
- </div>
379
- );
380
- },
381
- );
346
+ // Disable text selection during scrubbing
347
+ document.body.style.userSelect = 'none';
348
+
349
+ return () => {
350
+ document.removeEventListener('mousemove', handleMouseMove);
351
+ document.removeEventListener('mouseup', handleMouseUp);
352
+ document.body.style.userSelect = '';
353
+ };
354
+ }, [isScrubbing, processScrubMovement]);
355
+
356
+ // Hide native cursor and disable text selection during pointer capture scrubbing
357
+ React.useEffect(() => {
358
+ if (!isScrubbing || !usePointerCapture) return;
359
+
360
+ // Create a style element to hide cursor globally during scrub
361
+ const style = document.createElement('style');
362
+ style.textContent = '* { cursor: none !important; user-select: none !important; }';
363
+ document.head.appendChild(style);
364
+
365
+ return () => {
366
+ document.head.removeChild(style);
367
+ };
368
+ }, [isScrubbing]);
369
+
370
+ // Render virtual cursor via portal to body so it's not clipped
371
+ // Using a data URI of ew-resize cursor for a native look
372
+ const virtualCursor = isScrubbing
373
+ ? ReactDOM.createPortal(
374
+ <img
375
+ className="rt-TextFieldSlotScrubCursor"
376
+ src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='black' stroke='white' stroke-width='1' d='M0 12 L5 7 L5 10 L19 10 L19 7 L24 12 L19 17 L19 14 L5 14 L5 17 Z'/%3E%3C/svg%3E"
377
+ alt=""
378
+ style={{
379
+ position: 'fixed',
380
+ left: cursorPosition.x,
381
+ top: cursorPosition.y,
382
+ transform: 'translate(-50%, -50%)',
383
+ pointerEvents: 'none',
384
+ zIndex: 99999,
385
+ width: 24,
386
+ height: 24,
387
+ }}
388
+ aria-hidden="true"
389
+ />,
390
+ document.body,
391
+ )
392
+ : null;
393
+
394
+ return (
395
+ <div
396
+ data-accent-color={color}
397
+ data-side={side}
398
+ data-scrub={scrub || undefined}
399
+ data-scrubbing={isScrubbing || undefined}
400
+ {...slotProps}
401
+ ref={composeRefs(slotRef, forwardedRef)}
402
+ className={classNames('rt-TextFieldSlot', className)}
403
+ onPointerDown={scrub ? handlePointerDown : undefined}
404
+ onPointerMove={scrub ? handlePointerMove : undefined}
405
+ onPointerUp={scrub ? handlePointerUp : undefined}
406
+ onLostPointerCapture={scrub ? handleLostPointerCapture : undefined}
407
+ >
408
+ {slotProps.children}
409
+ {virtualCursor}
410
+ </div>
411
+ );
412
+ });
382
413
  TextFieldSlot.displayName = 'TextField.Slot';
383
414
 
384
415
  export { TextFieldRoot as Root, TextFieldSlot as Slot };