@dxos/react-input 0.8.4-main.bc674ce → 0.8.4-main.bd9b33e6c8
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/lib/browser/index.mjs +162 -39
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +162 -39
- package/dist/lib/node-esm/index.mjs.map +3 -3
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/PinInput.d.ts +5 -10
- package/dist/types/src/PinInput.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -4
- package/src/InputMeta.tsx +15 -15
- package/src/PinInput.tsx +176 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/react-input",
|
|
3
|
-
"version": "0.8.4-main.
|
|
3
|
+
"version": "0.8.4-main.bd9b33e6c8",
|
|
4
4
|
"description": "Input primitive components for React.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -31,9 +31,7 @@
|
|
|
31
31
|
"@radix-ui/react-primitive": "2.0.2",
|
|
32
32
|
"@radix-ui/react-slot": "1.1.2",
|
|
33
33
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
|
34
|
-
"
|
|
35
|
-
"rci": "^0.1.0",
|
|
36
|
-
"@dxos/react-hooks": "0.8.4-main.bc674ce"
|
|
34
|
+
"@dxos/react-hooks": "0.8.4-main.bd9b33e6c8"
|
|
37
35
|
},
|
|
38
36
|
"devDependencies": {
|
|
39
37
|
"@types/react": "~19.2.7",
|
package/src/InputMeta.tsx
CHANGED
|
@@ -13,11 +13,11 @@ type LabelProps = ComponentPropsWithRef<typeof Primitive.label> & { asChild?: bo
|
|
|
13
13
|
const Label = forwardRef<HTMLLabelElement, LabelProps>(
|
|
14
14
|
({ __inputScope, asChild, children, ...props }: InputScopedProps<LabelProps>, forwardedRef) => {
|
|
15
15
|
const { id } = useInputContext(INPUT_NAME, __inputScope);
|
|
16
|
-
const
|
|
16
|
+
const Comp = asChild ? Slot : Primitive.label;
|
|
17
17
|
return (
|
|
18
|
-
<
|
|
18
|
+
<Comp {...props} htmlFor={id} ref={forwardedRef}>
|
|
19
19
|
{children}
|
|
20
|
-
</
|
|
20
|
+
</Comp>
|
|
21
21
|
);
|
|
22
22
|
},
|
|
23
23
|
);
|
|
@@ -27,11 +27,11 @@ type DescriptionProps = Omit<ComponentPropsWithRef<typeof Primitive.span>, 'id'>
|
|
|
27
27
|
const Description = forwardRef<HTMLSpanElement, DescriptionProps>(
|
|
28
28
|
({ __inputScope, asChild, children, ...props }: InputScopedProps<DescriptionProps>, forwardedRef) => {
|
|
29
29
|
const { descriptionId, validationValence } = useInputContext(INPUT_NAME, __inputScope);
|
|
30
|
-
const
|
|
30
|
+
const Comp = asChild ? Slot : Primitive.span;
|
|
31
31
|
return (
|
|
32
|
-
<
|
|
32
|
+
<Comp {...props} {...(validationValence === 'error' && { id: descriptionId })} ref={forwardedRef}>
|
|
33
33
|
{children}
|
|
34
|
-
</
|
|
34
|
+
</Comp>
|
|
35
35
|
);
|
|
36
36
|
},
|
|
37
37
|
);
|
|
@@ -41,11 +41,11 @@ type ErrorMessageProps = Omit<ComponentPropsWithRef<typeof Primitive.span>, 'id'
|
|
|
41
41
|
const ErrorMessage = forwardRef<HTMLSpanElement, ErrorMessageProps>(
|
|
42
42
|
({ __inputScope, asChild, children, ...props }: InputScopedProps<ErrorMessageProps>, forwardedRef) => {
|
|
43
43
|
const { errorMessageId } = useInputContext(INPUT_NAME, __inputScope);
|
|
44
|
-
const
|
|
44
|
+
const Comp = asChild ? Slot : Primitive.span;
|
|
45
45
|
return (
|
|
46
|
-
<
|
|
46
|
+
<Comp {...props} id={errorMessageId} ref={forwardedRef}>
|
|
47
47
|
{children}
|
|
48
|
-
</
|
|
48
|
+
</Comp>
|
|
49
49
|
);
|
|
50
50
|
},
|
|
51
51
|
);
|
|
@@ -59,11 +59,11 @@ const Validation = forwardRef<HTMLSpanElement, ValidationProps>(
|
|
|
59
59
|
if (validationValence === 'error') {
|
|
60
60
|
return <ErrorMessage {...props} ref={forwardedRef} />;
|
|
61
61
|
} else {
|
|
62
|
-
const
|
|
62
|
+
const Comp = asChild ? Slot : Primitive.span;
|
|
63
63
|
return (
|
|
64
|
-
<
|
|
64
|
+
<Comp {...otherProps} ref={forwardedRef}>
|
|
65
65
|
{children}
|
|
66
|
-
</
|
|
66
|
+
</Comp>
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
},
|
|
@@ -74,11 +74,11 @@ type DescriptionAndValidationProps = ComponentPropsWithRef<typeof Primitive.p> &
|
|
|
74
74
|
const DescriptionAndValidation = forwardRef<HTMLParagraphElement, DescriptionAndValidationProps>(
|
|
75
75
|
({ __inputScope, asChild, children, ...props }: InputScopedProps<DescriptionAndValidationProps>, forwardedRef) => {
|
|
76
76
|
const { descriptionId, validationValence } = useInputContext(INPUT_NAME, __inputScope);
|
|
77
|
-
const
|
|
77
|
+
const Comp = asChild ? Slot : Primitive.p;
|
|
78
78
|
return (
|
|
79
|
-
<
|
|
79
|
+
<Comp {...props} {...(validationValence !== 'error' && { id: descriptionId })} ref={forwardedRef}>
|
|
80
80
|
{children}
|
|
81
|
-
</
|
|
81
|
+
</Comp>
|
|
82
82
|
);
|
|
83
83
|
},
|
|
84
84
|
);
|
package/src/PinInput.tsx
CHANGED
|
@@ -2,75 +2,206 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
5
|
+
import React, {
|
|
6
|
+
type ChangeEvent,
|
|
7
|
+
type ClipboardEvent,
|
|
8
|
+
type ComponentPropsWithRef,
|
|
9
|
+
type KeyboardEvent,
|
|
10
|
+
forwardRef,
|
|
11
|
+
useCallback,
|
|
12
|
+
useEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useState,
|
|
15
|
+
} from 'react';
|
|
7
16
|
|
|
8
17
|
import { useForwardedRef, useIsFocused } from '@dxos/react-hooks';
|
|
9
18
|
|
|
10
|
-
import { INPUT_NAME, type InputScopedProps,
|
|
19
|
+
import { INPUT_NAME, type InputScopedProps, useInputContext } from './Root';
|
|
11
20
|
|
|
12
|
-
type PinInputProps = Omit<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
segmentClassName?: (styleProps: { focused: boolean; validationValence: Valence }) => string;
|
|
18
|
-
segmentPadding?: string;
|
|
19
|
-
segmentHeight?: string;
|
|
21
|
+
type PinInputProps = Omit<ComponentPropsWithRef<'input'>, 'type' | 'maxLength'> & {
|
|
22
|
+
/** Class name applied to each segment div. */
|
|
23
|
+
segmentClassName?: string;
|
|
24
|
+
/** Number of code segments. */
|
|
25
|
+
length?: number;
|
|
20
26
|
};
|
|
21
27
|
|
|
22
28
|
const PinInput = forwardRef<HTMLInputElement, PinInputProps>(
|
|
23
29
|
(
|
|
24
30
|
{
|
|
25
31
|
__inputScope,
|
|
32
|
+
className,
|
|
33
|
+
disabled,
|
|
26
34
|
segmentClassName,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
length = 6,
|
|
36
|
+
pattern,
|
|
37
|
+
value: controlledValue,
|
|
38
|
+
onChange,
|
|
39
|
+
onPaste,
|
|
30
40
|
...props
|
|
31
41
|
}: InputScopedProps<PinInputProps>,
|
|
32
42
|
forwardedRef,
|
|
33
43
|
) => {
|
|
34
44
|
const { id, validationValence, descriptionId, errorMessageId } = useInputContext(INPUT_NAME, __inputScope);
|
|
35
|
-
const width = getSegmentCssWidth(segmentPadding);
|
|
36
45
|
const inputRef = useForwardedRef(forwardedRef);
|
|
37
46
|
const inputFocused = useIsFocused(inputRef);
|
|
47
|
+
const [internalValue, setInternalValue] = useState('');
|
|
48
|
+
const [cursorPosition, setCursorPosition] = useState(0);
|
|
38
49
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const value = controlledValue != null ? String(controlledValue) : internalValue;
|
|
51
|
+
|
|
52
|
+
// Derive a per-character filter from the `pattern` prop (e.g., `\\d*` → test each char against `\\d`).
|
|
53
|
+
const charPattern = useMemo(() => {
|
|
54
|
+
if (!pattern) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
// Strip quantifiers (*, +, {n}) to get the base character class.
|
|
59
|
+
const base = pattern.replace(/[*+?]$|\{\d+,?\d*\}$/g, '');
|
|
60
|
+
return new RegExp(`^${base}$`);
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}, [pattern]);
|
|
65
|
+
|
|
66
|
+
/** Filter a string to only characters matching the pattern. */
|
|
67
|
+
const filterValue = useCallback(
|
|
68
|
+
(input: string) => {
|
|
69
|
+
if (!charPattern) {
|
|
70
|
+
return input;
|
|
71
|
+
}
|
|
72
|
+
return input
|
|
73
|
+
.split('')
|
|
74
|
+
.filter((char) => charPattern.test(char))
|
|
75
|
+
.join('');
|
|
76
|
+
},
|
|
77
|
+
[charPattern],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Sync cursor position from the hidden input's selection.
|
|
81
|
+
const syncCursor = useCallback(() => {
|
|
82
|
+
const pos = inputRef.current?.selectionStart ?? value.length;
|
|
83
|
+
setCursorPosition(Math.min(pos, value.length));
|
|
84
|
+
}, [inputRef, value.length]);
|
|
85
|
+
|
|
86
|
+
// Keep cursor in sync after value changes.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
setCursorPosition((prev) => Math.min(prev, value.length));
|
|
89
|
+
}, [value.length]);
|
|
90
|
+
|
|
91
|
+
const handleChange = useCallback(
|
|
92
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
93
|
+
const newValue = filterValue(event.target.value).slice(0, length);
|
|
94
|
+
if (controlledValue == null) {
|
|
95
|
+
setInternalValue(newValue);
|
|
96
|
+
}
|
|
97
|
+
setCursorPosition(event.target.selectionStart ?? newValue.length);
|
|
98
|
+
onChange?.(event);
|
|
99
|
+
},
|
|
100
|
+
[length, controlledValue, onChange, filterValue],
|
|
52
101
|
);
|
|
53
102
|
|
|
103
|
+
const handlePaste = useCallback(
|
|
104
|
+
(event: ClipboardEvent<HTMLInputElement>) => {
|
|
105
|
+
onPaste?.(event);
|
|
106
|
+
if (event.defaultPrevented) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
const pasted = filterValue(event.clipboardData.getData('text/plain')).slice(0, length);
|
|
111
|
+
const input = inputRef.current;
|
|
112
|
+
if (!input) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Use native setter to trigger React's synthetic onChange.
|
|
116
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
117
|
+
nativeInputValueSetter?.call(input, pasted);
|
|
118
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
119
|
+
},
|
|
120
|
+
[length, inputRef, onPaste, filterValue],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const handleKeyDown = useCallback(
|
|
124
|
+
(event: KeyboardEvent<HTMLInputElement>) => {
|
|
125
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
126
|
+
// Let the native input handle cursor movement, then sync.
|
|
127
|
+
requestAnimationFrame(syncCursor);
|
|
128
|
+
} else if (event.key === 'Backspace' && value.length === 0) {
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
} else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
131
|
+
// Reject characters that don't match the allow pattern.
|
|
132
|
+
if (charPattern && !charPattern.test(event.key)) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
props.onKeyDown?.(event);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Overwrite mode: replace character at cursor position instead of inserting.
|
|
138
|
+
const input = inputRef.current;
|
|
139
|
+
const pos = input?.selectionStart ?? value.length;
|
|
140
|
+
if (pos < value.length && input) {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
const newValue = value.slice(0, pos) + event.key + value.slice(pos + 1);
|
|
143
|
+
const newPos = Math.min(pos + 1, length);
|
|
144
|
+
// Update state and cursor synchronously to avoid flicker.
|
|
145
|
+
if (controlledValue == null) {
|
|
146
|
+
setInternalValue(newValue);
|
|
147
|
+
}
|
|
148
|
+
setCursorPosition(newPos);
|
|
149
|
+
// Sync the native input to match.
|
|
150
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
151
|
+
nativeInputValueSetter?.call(input, newValue);
|
|
152
|
+
input.setSelectionRange(newPos, newPos);
|
|
153
|
+
// Notify consumer via onChange with a synthetic-like event.
|
|
154
|
+
onChange?.({ target: input, currentTarget: input } as ChangeEvent<HTMLInputElement>);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
props.onKeyDown?.(event);
|
|
158
|
+
},
|
|
159
|
+
[value, length, props.onKeyDown, syncCursor, inputRef, charPattern, controlledValue, onChange],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const handleSelect = useCallback(() => {
|
|
163
|
+
syncCursor();
|
|
164
|
+
}, [syncCursor]);
|
|
165
|
+
|
|
166
|
+
const activeIndex = Math.min(cursorPosition, value.length < length ? value.length : length - 1);
|
|
167
|
+
|
|
54
168
|
return (
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
169
|
+
<div className={`relative inline-flex items-center gap-2 ${className ?? ''}`}>
|
|
170
|
+
<input
|
|
171
|
+
ref={inputRef}
|
|
172
|
+
id={id}
|
|
173
|
+
type='text'
|
|
174
|
+
value={value}
|
|
175
|
+
onChange={handleChange}
|
|
176
|
+
onPaste={handlePaste}
|
|
177
|
+
onKeyDown={handleKeyDown}
|
|
178
|
+
onSelect={handleSelect}
|
|
179
|
+
maxLength={length}
|
|
180
|
+
disabled={disabled}
|
|
181
|
+
spellCheck={false}
|
|
182
|
+
aria-describedby={descriptionId}
|
|
183
|
+
{...(validationValence === 'error' && {
|
|
66
184
|
'aria-invalid': 'true' as const,
|
|
67
185
|
'aria-errormessage': errorMessageId,
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
className
|
|
72
|
-
|
|
73
|
-
|
|
186
|
+
})}
|
|
187
|
+
{...props}
|
|
188
|
+
pattern={pattern}
|
|
189
|
+
className='dx-fullscreen opacity-0'
|
|
190
|
+
style={{
|
|
191
|
+
caretColor: 'transparent',
|
|
192
|
+
...props.style,
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
195
|
+
{Array.from({ length }, (_, index) => {
|
|
196
|
+
const char = value[index] || '\u00A0';
|
|
197
|
+
const isCursor = !!(inputFocused && index === activeIndex);
|
|
198
|
+
return (
|
|
199
|
+
<div key={index} className={segmentClassName} {...(isCursor && { 'data-focused': '' })} aria-hidden='true'>
|
|
200
|
+
{char}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
74
205
|
);
|
|
75
206
|
},
|
|
76
207
|
);
|