@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.
- package/components.css +12 -1
- package/dist/cjs/components/text-field.d.ts.map +1 -1
- package/dist/cjs/components/text-field.js +2 -2
- package/dist/cjs/components/text-field.js.map +3 -3
- package/dist/esm/components/text-field.d.ts.map +1 -1
- package/dist/esm/components/text-field.js +2 -2
- package/dist/esm/components/text-field.js.map +3 -3
- package/package.json +1 -1
- package/schemas/base-button.json +1 -1
- package/schemas/button.json +1 -1
- package/schemas/icon-button.json +1 -1
- package/schemas/index.json +6 -6
- package/schemas/toggle-button.json +1 -1
- package/schemas/toggle-icon-button.json +1 -1
- package/src/components/chatbar.css +5 -1
- package/src/components/scroll-area.css +2 -0
- package/src/components/shell.css +12 -0
- package/src/components/text-field.tsx +378 -347
- package/styles.css +12 -1
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
337
|
+
const handleMouseUp = () => {
|
|
338
|
+
// Exit pointer lock to end scrubbing
|
|
339
|
+
document.exitPointerLock();
|
|
340
|
+
};
|
|
207
341
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 };
|