@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-input",
3
- "version": "0.8.4-main.bc674ce",
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
- "lodash.omit": "^4.5.0",
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 Root = asChild ? Slot : Primitive.label;
16
+ const Comp = asChild ? Slot : Primitive.label;
17
17
  return (
18
- <Root {...props} htmlFor={id} ref={forwardedRef}>
18
+ <Comp {...props} htmlFor={id} ref={forwardedRef}>
19
19
  {children}
20
- </Root>
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 Root = asChild ? Slot : Primitive.span;
30
+ const Comp = asChild ? Slot : Primitive.span;
31
31
  return (
32
- <Root {...props} {...(validationValence === 'error' && { id: descriptionId })} ref={forwardedRef}>
32
+ <Comp {...props} {...(validationValence === 'error' && { id: descriptionId })} ref={forwardedRef}>
33
33
  {children}
34
- </Root>
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 Root = asChild ? Slot : Primitive.span;
44
+ const Comp = asChild ? Slot : Primitive.span;
45
45
  return (
46
- <Root {...props} id={errorMessageId} ref={forwardedRef}>
46
+ <Comp {...props} id={errorMessageId} ref={forwardedRef}>
47
47
  {children}
48
- </Root>
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 Root = asChild ? Slot : Primitive.span;
62
+ const Comp = asChild ? Slot : Primitive.span;
63
63
  return (
64
- <Root {...otherProps} ref={forwardedRef}>
64
+ <Comp {...otherProps} ref={forwardedRef}>
65
65
  {children}
66
- </Root>
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 Root = asChild ? Slot : Primitive.p;
77
+ const Comp = asChild ? Slot : Primitive.p;
78
78
  return (
79
- <Root {...props} {...(validationValence !== 'error' && { id: descriptionId })} ref={forwardedRef}>
79
+ <Comp {...props} {...(validationValence !== 'error' && { id: descriptionId })} ref={forwardedRef}>
80
80
  {children}
81
- </Root>
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 { CodeInput, getSegmentCssWidth } from 'rci';
6
- import React, { type ComponentProps, type ComponentPropsWithRef, type RefObject, forwardRef, useCallback } from 'react';
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, type Valence, useInputContext } from './Root';
19
+ import { INPUT_NAME, type InputScopedProps, useInputContext } from './Root';
11
20
 
12
- type PinInputProps = Omit<
13
- ComponentPropsWithRef<typeof CodeInput>,
14
- 'id' | 'className' | 'inputRef' | 'renderSegment'
15
- > & {
16
- inputClassName?: string;
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
- inputClassName,
28
- segmentPadding = '8px',
29
- segmentHeight = '100%',
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 renderSegment = useCallback<ComponentProps<typeof CodeInput>['renderSegment']>(
40
- ({ state, index }) => (
41
- <div
42
- key={index}
43
- className={segmentClassName?.({
44
- focused: !!(inputFocused && state),
45
- validationValence,
46
- })}
47
- data-state={state}
48
- style={{ width, height: segmentHeight }}
49
- />
50
- ),
51
- [segmentClassName, inputFocused, validationValence],
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
- <CodeInput
56
- {...{
57
- padding: '8px',
58
- spacing: '8px',
59
- fontFamily: '',
60
- spellCheck: false,
61
- length: 6,
62
- ...props,
63
- id,
64
- 'aria-describedby': descriptionId,
65
- ...(validationValence === 'error' && {
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
- inputRef: inputRef as RefObject<HTMLInputElement>,
70
- renderSegment,
71
- className: inputClassName,
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
  );