@radix-ui/react-one-time-password-field 0.1.0-rc.1744898528774 → 0.1.0-rc.1744910682821

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": "@radix-ui/react-one-time-password-field",
3
- "version": "0.1.0-rc.1744898528774",
3
+ "version": "0.1.0-rc.1744910682821",
4
4
  "license": "MIT",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./dist/index.js",
@@ -13,17 +13,17 @@
13
13
  "sideEffects": false,
14
14
  "dependencies": {
15
15
  "@radix-ui/primitive": "1.1.2",
16
- "@radix-ui/react-collection": "1.1.4-rc.1744898528774",
17
- "@radix-ui/react-compose-refs": "1.1.2",
16
+ "@radix-ui/react-collection": "1.1.4-rc.1744910682821",
18
17
  "@radix-ui/react-context": "1.1.2",
19
18
  "@radix-ui/react-direction": "1.1.1",
19
+ "@radix-ui/react-compose-refs": "1.1.2",
20
20
  "@radix-ui/number": "1.1.1",
21
- "@radix-ui/react-primitive": "2.1.0-rc.1744898528774",
22
- "@radix-ui/react-use-controllable-state": "1.2.0-rc.1744898528774",
23
- "@radix-ui/react-roving-focus": "1.1.4-rc.1744898528774",
21
+ "@radix-ui/react-roving-focus": "1.1.4-rc.1744910682821",
22
+ "@radix-ui/react-primitive": "2.1.0-rc.1744910682821",
23
+ "@radix-ui/react-use-controllable-state": "1.2.0-rc.1744910682821",
24
24
  "@radix-ui/react-use-effect-event": "0.0.0",
25
- "@radix-ui/react-use-is-hydrated": "0.0.0",
26
- "@radix-ui/react-use-layout-effect": "1.1.1"
25
+ "@radix-ui/react-use-layout-effect": "1.1.1",
26
+ "@radix-ui/react-use-is-hydrated": "0.0.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/react": "^19.0.7",
@@ -62,6 +62,7 @@ interface OneTimePasswordFieldContextValue {
62
62
  userActionRef: React.RefObject<KeyboardActionDetails | null>;
63
63
  validationType: InputValidationType;
64
64
  value: string[];
65
+ sanitizeValue: (arg: string | string[]) => string[];
65
66
  }
66
67
 
67
68
  const ONE_TIME_PASSWORD_FIELD_NAME = 'OneTimePasswordField';
@@ -457,6 +458,7 @@ const OneTimePasswordField = React.forwardRef<HTMLDivElement, OneTimePasswordFie
457
458
  orientation={orientation}
458
459
  preHydrationIndexTracker={preHydrationIndexTracker}
459
460
  isHydrated={isHydrated}
461
+ sanitizeValue={sanitizeValue}
460
462
  >
461
463
  <Collection.Provider scope={__scopeOneTimePasswordField} state={collectionState}>
462
464
  <Collection.Slot scope={__scopeOneTimePasswordField}>
@@ -475,10 +477,6 @@ const OneTimePasswordField = React.forwardRef<HTMLDivElement, OneTimePasswordFie
475
477
  (event: React.ClipboardEvent<HTMLDivElement>) => {
476
478
  event.preventDefault();
477
479
  const pastedValue = event.clipboardData.getData('Text');
478
- const value = sanitizeValue(pastedValue);
479
- if (!value) {
480
- return;
481
- }
482
480
  dispatch({ type: 'PASTE', value: pastedValue });
483
481
  }
484
482
  )}
@@ -642,133 +640,40 @@ const OneTimePasswordFieldInput = React.forwardRef<
642
640
  focusable={!context.disabled && isFocusable}
643
641
  active={index === lastSelectableIndex}
644
642
  >
645
- <Primitive.Root.input
646
- ref={composedInputRef}
647
- type="text"
648
- aria-label={`Character ${index + 1} of ${collection.size}`}
649
- autoComplete={index === 0 ? context.autoComplete : 'off'}
650
- inputMode={validation?.inputMode}
651
- maxLength={1}
652
- pattern={validation?.pattern}
653
- readOnly={context.readOnly}
654
- value={char}
655
- placeholder={placeholder}
656
- data-radix-otp-input=""
657
- data-radix-index={index}
658
- {...domProps}
659
- onFocus={composeEventHandlers(props.onFocus, (event) => {
660
- event.currentTarget.select();
661
- })}
662
- onCut={composeEventHandlers(props.onCut, (event) => {
663
- const currentValue = event.currentTarget.value;
664
- if (currentValue !== '') {
665
- // In this case the value will be cleared, but we don't want to
666
- // set it directly because the user may want to prevent default
667
- // behavior in the onChange handler. The userActionRef will
668
- // is set temporarily so the change handler can behave correctly
669
- // in response to the action.
670
- userActionRef.current = {
671
- type: 'cut',
672
- };
673
- // Set a short timeout to clear the action tracker after the change
674
- // handler has had time to complete.
675
- keyboardActionTimeoutRef.current = window.setTimeout(() => {
676
- userActionRef.current = null;
677
- }, 10);
678
- }
679
- })}
680
- onChange={composeEventHandlers(props.onChange, (event) => {
681
- event.preventDefault();
682
- const action = userActionRef.current;
683
- userActionRef.current = null;
684
-
685
- if (action) {
686
- switch (action.type) {
687
- case 'cut':
688
- // TODO: do we want to assume the user wantt to clear the
689
- // entire value here and copy the code to the clipboard instead
690
- // of just the value of the given input?
691
- dispatch({ type: 'CLEAR_CHAR', index, reason: 'Cut' });
692
- return;
693
- case 'keydown': {
694
- if (action.key === 'Char') {
695
- // update resulting from a keydown event that set a value
696
- // directly. Ignore.
697
- return;
698
- }
699
-
700
- const isClearing =
701
- action.key === 'Backspace' && (action.metaKey || action.ctrlKey);
702
- if (action.key === 'Clear' || isClearing) {
703
- dispatch({ type: 'CLEAR', reason: 'Backspace' });
704
- } else {
705
- dispatch({ type: 'CLEAR_CHAR', index, reason: action.key });
706
- }
707
- return;
708
- }
709
- default:
710
- return;
711
- }
712
- }
713
-
714
- // Only update the value if it matches the input pattern
715
- if (event.target.validity.valid) {
716
- if (event.target.value === '') {
717
- let reason: 'Backspace' | 'Delete' | 'Cut' = 'Backspace';
718
- if (isInputEvent(event.nativeEvent)) {
719
- const inputType = event.nativeEvent.inputType;
720
- if (inputType === 'deleteContentBackward') {
721
- reason = 'Backspace';
722
- } else if (inputType === 'deleteByCut') {
723
- reason = 'Cut';
724
- }
725
- }
726
- dispatch({ type: 'CLEAR_CHAR', index, reason });
727
- } else {
728
- dispatch({ type: 'SET_CHAR', char: event.target.value, index, event });
729
- }
730
- } else {
731
- const element = event.target;
732
- onInvalidChange?.(element.value);
733
- requestAnimationFrame(() => {
734
- if (element.ownerDocument.activeElement === element) {
735
- element.select();
736
- }
737
- });
738
- }
739
- })}
740
- onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
741
- switch (event.key) {
742
- case 'Clear':
743
- case 'Delete':
744
- case 'Backspace': {
643
+ {({ isCurrentTabStop }) => {
644
+ const supportsAutoComplete = isHydrated ? isCurrentTabStop : index === 0;
645
+ return (
646
+ <Primitive.Root.input
647
+ ref={composedInputRef}
648
+ type="text"
649
+ aria-label={`Character ${index + 1} of ${collection.size}`}
650
+ autoComplete={supportsAutoComplete ? context.autoComplete : 'off'}
651
+ data-1p-ignore={supportsAutoComplete ? undefined : 'true'}
652
+ data-lpignore={supportsAutoComplete ? undefined : 'true'}
653
+ data-protonpass-ignore={supportsAutoComplete ? undefined : 'true'}
654
+ data-bwignore={supportsAutoComplete ? undefined : 'true'}
655
+ inputMode={validation?.inputMode}
656
+ maxLength={1}
657
+ pattern={validation?.pattern}
658
+ readOnly={context.readOnly}
659
+ value={char}
660
+ placeholder={placeholder}
661
+ data-radix-otp-input=""
662
+ data-radix-index={index}
663
+ {...domProps}
664
+ onFocus={composeEventHandlers(props.onFocus, (event) => {
665
+ event.currentTarget.select();
666
+ })}
667
+ onCut={composeEventHandlers(props.onCut, (event) => {
745
668
  const currentValue = event.currentTarget.value;
746
- // if current value is empty, no change event will fire
747
- if (currentValue === '') {
748
- // if the user presses delete when there is no value, noop
749
- if (event.key === 'Delete') return;
750
-
751
- const isClearing = event.key === 'Clear' || event.metaKey || event.ctrlKey;
752
- if (isClearing) {
753
- dispatch({ type: 'CLEAR', reason: 'Backspace' });
754
- } else {
755
- const element = event.currentTarget;
756
- requestAnimationFrame(() => {
757
- focusInput(collection.from(element, -1)?.element);
758
- });
759
- }
760
- } else {
761
- // In this case the value will be cleared, but we don't want
762
- // to set it directly because the user may want to prevent
763
- // default behavior in the onChange handler. The userActionRef
764
- // will is set temporarily so the change handler can behave
765
- // correctly in response to the key vs. clearing the value by
766
- // setting state externally.
669
+ if (currentValue !== '') {
670
+ // In this case the value will be cleared, but we don't want to
671
+ // set it directly because the user may want to prevent default
672
+ // behavior in the onChange handler. The userActionRef will
673
+ // is set temporarily so the change handler can behave correctly
674
+ // in response to the action.
767
675
  userActionRef.current = {
768
- type: 'keydown',
769
- key: event.key,
770
- metaKey: event.metaKey,
771
- ctrlKey: event.ctrlKey,
676
+ type: 'cut',
772
677
  };
773
678
  // Set a short timeout to clear the action tracker after the change
774
679
  // handler has had time to complete.
@@ -776,90 +681,203 @@ const OneTimePasswordFieldInput = React.forwardRef<
776
681
  userActionRef.current = null;
777
682
  }, 10);
778
683
  }
779
-
780
- return;
781
- }
782
- case 'Enter': {
783
- event.preventDefault();
784
- context.attemptSubmit();
785
- return;
786
- }
787
- case 'ArrowDown':
788
- case 'ArrowUp': {
789
- if (context.orientation === 'horizontal') {
790
- // in horizontal orientation, the up/down will de-select the
791
- // input instead of moving focus
684
+ })}
685
+ onInput={composeEventHandlers(props.onInput, (event) => {
686
+ const value = event.currentTarget.value;
687
+ if (value.length > 1) {
688
+ // Password managers may try to insert the code into a single
689
+ // input, in which case form validation will fail to prevent
690
+ // additional input. Handle this the same as if a user were
691
+ // pasting a value.
792
692
  event.preventDefault();
693
+ dispatch({ type: 'PASTE', value });
793
694
  }
794
- return;
795
- }
796
- // TODO: Handle left/right arrow keys in vertical writing mode
797
- default: {
798
- if (event.currentTarget.value === event.key) {
799
- // if current value is same as the key press, no change event
800
- // will fire. Focus the next input.
801
- const element = event.currentTarget;
802
- event.preventDefault();
803
- focusInput(collection.from(element, 1)?.element);
804
- return;
805
- } else if (
806
- // input already has a value, but...
807
- event.currentTarget.value &&
808
- // the value is not selected
809
- !(
810
- event.currentTarget.selectionStart === 0 &&
811
- event.currentTarget.selectionEnd != null &&
812
- event.currentTarget.selectionEnd > 0
813
- )
814
- ) {
815
- const attemptedValue = event.key;
816
- if (event.key.length > 1 || event.key === ' ') {
817
- // not a character; do nothing
818
- return;
695
+ })}
696
+ onChange={composeEventHandlers(props.onChange, (event) => {
697
+ const value = event.target.value;
698
+ event.preventDefault();
699
+ const action = userActionRef.current;
700
+ userActionRef.current = null;
701
+
702
+ if (action) {
703
+ switch (action.type) {
704
+ case 'cut':
705
+ // TODO: do we want to assume the user wantt to clear the
706
+ // entire value here and copy the code to the clipboard instead
707
+ // of just the value of the given input?
708
+ dispatch({ type: 'CLEAR_CHAR', index, reason: 'Cut' });
709
+ return;
710
+ case 'keydown': {
711
+ if (action.key === 'Char') {
712
+ // update resulting from a keydown event that set a value
713
+ // directly. Ignore.
714
+ return;
715
+ }
716
+
717
+ const isClearing =
718
+ action.key === 'Backspace' && (action.metaKey || action.ctrlKey);
719
+ if (action.key === 'Clear' || isClearing) {
720
+ dispatch({ type: 'CLEAR', reason: 'Backspace' });
721
+ } else {
722
+ dispatch({ type: 'CLEAR_CHAR', index, reason: action.key });
723
+ }
724
+ return;
725
+ }
726
+ default:
727
+ return;
728
+ }
729
+ }
730
+
731
+ // Only update the value if it matches the input pattern
732
+ if (event.target.validity.valid) {
733
+ if (value === '') {
734
+ let reason: 'Backspace' | 'Delete' | 'Cut' = 'Backspace';
735
+ if (isInputEvent(event.nativeEvent)) {
736
+ const inputType = event.nativeEvent.inputType;
737
+ if (inputType === 'deleteContentBackward') {
738
+ reason = 'Backspace';
739
+ } else if (inputType === 'deleteByCut') {
740
+ reason = 'Cut';
741
+ }
742
+ }
743
+ dispatch({ type: 'CLEAR_CHAR', index, reason });
819
744
  } else {
820
- // user is attempting to enter a character, but the input
821
- // will not update by default since it's limited to a single
822
- // character.
823
- const nextInput = collection.from(event.currentTarget, 1)?.element;
824
- const lastInput = collection.at(-1)?.element;
825
- if (nextInput !== lastInput && event.currentTarget !== lastInput) {
826
- // if selection is before the value, set the value of the
827
- // current input. Otherwise set the value of the next
828
- // input.
829
- if (event.currentTarget.selectionStart === 0) {
830
- dispatch({ type: 'SET_CHAR', char: attemptedValue, index, event });
745
+ dispatch({ type: 'SET_CHAR', char: value, index, event });
746
+ }
747
+ } else {
748
+ const element = event.target;
749
+ onInvalidChange?.(element.value);
750
+ requestAnimationFrame(() => {
751
+ if (element.ownerDocument.activeElement === element) {
752
+ element.select();
753
+ }
754
+ });
755
+ }
756
+ })}
757
+ onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
758
+ switch (event.key) {
759
+ case 'Clear':
760
+ case 'Delete':
761
+ case 'Backspace': {
762
+ const currentValue = event.currentTarget.value;
763
+ // if current value is empty, no change event will fire
764
+ if (currentValue === '') {
765
+ // if the user presses delete when there is no value, noop
766
+ if (event.key === 'Delete') return;
767
+
768
+ const isClearing = event.key === 'Clear' || event.metaKey || event.ctrlKey;
769
+ if (isClearing) {
770
+ dispatch({ type: 'CLEAR', reason: 'Backspace' });
831
771
  } else {
832
- dispatch({
833
- type: 'SET_CHAR',
834
- char: attemptedValue,
835
- index: index + 1,
836
- event,
772
+ const element = event.currentTarget;
773
+ requestAnimationFrame(() => {
774
+ focusInput(collection.from(element, -1)?.element);
837
775
  });
838
776
  }
839
-
777
+ } else {
778
+ // In this case the value will be cleared, but we don't want
779
+ // to set it directly because the user may want to prevent
780
+ // default behavior in the onChange handler. The userActionRef
781
+ // will is set temporarily so the change handler can behave
782
+ // correctly in response to the key vs. clearing the value by
783
+ // setting state externally.
840
784
  userActionRef.current = {
841
785
  type: 'keydown',
842
- key: 'Char',
786
+ key: event.key,
843
787
  metaKey: event.metaKey,
844
788
  ctrlKey: event.ctrlKey,
845
789
  };
790
+ // Set a short timeout to clear the action tracker after the change
791
+ // handler has had time to complete.
846
792
  keyboardActionTimeoutRef.current = window.setTimeout(() => {
847
793
  userActionRef.current = null;
848
794
  }, 10);
849
795
  }
796
+
797
+ return;
798
+ }
799
+ case 'Enter': {
800
+ event.preventDefault();
801
+ context.attemptSubmit();
802
+ return;
803
+ }
804
+ case 'ArrowDown':
805
+ case 'ArrowUp': {
806
+ if (context.orientation === 'horizontal') {
807
+ // in horizontal orientation, the up/down will de-select the
808
+ // input instead of moving focus
809
+ event.preventDefault();
810
+ }
811
+ return;
812
+ }
813
+ // TODO: Handle left/right arrow keys in vertical writing mode
814
+ default: {
815
+ if (event.currentTarget.value === event.key) {
816
+ // if current value is same as the key press, no change event
817
+ // will fire. Focus the next input.
818
+ const element = event.currentTarget;
819
+ event.preventDefault();
820
+ focusInput(collection.from(element, 1)?.element);
821
+ return;
822
+ } else if (
823
+ // input already has a value, but...
824
+ event.currentTarget.value &&
825
+ // the value is not selected
826
+ !(
827
+ event.currentTarget.selectionStart === 0 &&
828
+ event.currentTarget.selectionEnd != null &&
829
+ event.currentTarget.selectionEnd > 0
830
+ )
831
+ ) {
832
+ const attemptedValue = event.key;
833
+ if (event.key.length > 1 || event.key === ' ') {
834
+ // not a character; do nothing
835
+ return;
836
+ } else {
837
+ // user is attempting to enter a character, but the input
838
+ // will not update by default since it's limited to a single
839
+ // character.
840
+ const nextInput = collection.from(event.currentTarget, 1)?.element;
841
+ const lastInput = collection.at(-1)?.element;
842
+ if (nextInput !== lastInput && event.currentTarget !== lastInput) {
843
+ // if selection is before the value, set the value of the
844
+ // current input. Otherwise set the value of the next
845
+ // input.
846
+ if (event.currentTarget.selectionStart === 0) {
847
+ dispatch({ type: 'SET_CHAR', char: attemptedValue, index, event });
848
+ } else {
849
+ dispatch({
850
+ type: 'SET_CHAR',
851
+ char: attemptedValue,
852
+ index: index + 1,
853
+ event,
854
+ });
855
+ }
856
+
857
+ userActionRef.current = {
858
+ type: 'keydown',
859
+ key: 'Char',
860
+ metaKey: event.metaKey,
861
+ ctrlKey: event.ctrlKey,
862
+ };
863
+ keyboardActionTimeoutRef.current = window.setTimeout(() => {
864
+ userActionRef.current = null;
865
+ }, 10);
866
+ }
867
+ }
868
+ }
850
869
  }
851
870
  }
852
- }
853
- }
854
- })}
855
- onPointerDown={composeEventHandlers(props.onPointerDown, (event) => {
856
- if (index > lastSelectableIndex) {
857
- event.preventDefault();
858
- const element = collection.at(lastSelectableIndex)?.element;
859
- focusInput(element);
860
- }
861
- })}
862
- />
871
+ })}
872
+ onPointerDown={composeEventHandlers(props.onPointerDown, (event) => {
873
+ event.preventDefault();
874
+ const indexToFocus = Math.min(index, lastSelectableIndex);
875
+ const element = collection.at(indexToFocus)?.element;
876
+ focusInput(element);
877
+ })}
878
+ />
879
+ );
880
+ }}
863
881
  </RovingFocusGroup.Item>
864
882
  </Collection.ItemSlot>
865
883
  );