From 3764e05eb4b5b9dedec941f08d9fc83e627dac60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=B3=E5=BB=B7=E5=AE=89?= <73953029+nrps9909@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:18:31 +0800 Subject: [PATCH] fix: support Android IME mask input --- src/PickerInput/Selector/Input.tsx | 71 ++++++++++++++++++++---------- tests/keyboard.spec.tsx | 56 +++++++++++++++++++++++ 2 files changed, 104 insertions(+), 23 deletions(-) diff --git a/src/PickerInput/Selector/Input.tsx b/src/PickerInput/Selector/Input.tsx index 38a1fbe2a..f17b7d5f5 100644 --- a/src/PickerInput/Selector/Input.tsx +++ b/src/PickerInput/Selector/Input.tsx @@ -97,6 +97,9 @@ const Input = React.forwardRef((props, ref) => { const inputRef = React.useRef(null); // When mousedown get focus, defer selection to mouseUp so click position is used const mouseDownRef = React.useRef(false); + // Android virtual keyboards may report `Unidentified` on keydown. In that + // case, use the following native input event to recover the intended key. + const nativeInputRef = React.useRef(false); React.useImperativeHandle(ref, () => ({ nativeElement: holderRef.current, @@ -140,18 +143,6 @@ const Input = React.forwardRef((props, ref) => { onModify(text); }); - // Directly trigger `onChange` if `format` is empty - const onInternalChange: React.ChangeEventHandler = (event) => { - // Hack `onChange` with format to do nothing - if (!format) { - const text = event.target.value; - - onModify(text); - setInputValue(text); - onChange(text); - } - }; - const onFormatPaste: React.ClipboardEventHandler = (event) => { // Block paste until selection is set (after mouseUp when focus was by mousedown) if (mouseDownRef.current) { @@ -203,6 +194,7 @@ const Input = React.forwardRef((props, ref) => { const onFormatBlur: React.FocusEventHandler = (event) => { setFocused(false); + nativeInputRef.current = false; onSharedBlur(event); }; @@ -224,17 +216,7 @@ const Input = React.forwardRef((props, ref) => { onKeyDown?.(event); }; - const onFormatKeyDown: React.KeyboardEventHandler = (event) => { - // Block key input until selection is set (after mouseUp when focus was by mousedown) - if (mouseDownRef.current) { - event.preventDefault(); - return; - } - - onSharedKeyDown(event); - - const { key } = event; - + const triggerFormatKey = (key: string) => { // Save the cache with cell text let nextCellText: string = null; @@ -340,6 +322,49 @@ const Input = React.forwardRef((props, ref) => { forceSelectionSync({}); }; + const onFormatKeyDown: React.KeyboardEventHandler = (event) => { + // Block key input until selection is set (after mouseUp when focus was by mousedown) + if (mouseDownRef.current) { + event.preventDefault(); + return; + } + + onSharedKeyDown(event); + + const { key } = event; + nativeInputRef.current = key === 'Unidentified'; + + if (!nativeInputRef.current) { + triggerFormatKey(key); + } + }; + + // Directly trigger `onChange` if `format` is empty. Masked inputs normally + // use keydown, but Android IMEs expose the inserted text on the input event. + const onInternalChange: React.ChangeEventHandler = (event) => { + if (!format) { + const text = event.target.value; + + onModify(text); + setInputValue(text); + onChange(text); + return; + } + + if (nativeInputRef.current) { + nativeInputRef.current = false; + + const nativeEvent = event.nativeEvent as InputEvent; + if (nativeEvent.inputType === 'deleteContentBackward') { + triggerFormatKey('Backspace'); + } else if (nativeEvent.inputType === 'deleteContentForward') { + triggerFormatKey('Delete'); + } else if (nativeEvent.data) { + triggerFormatKey(nativeEvent.data); + } + } + }; + // ======================== Format ======================== const rafRef = React.useRef(undefined); diff --git a/tests/keyboard.spec.tsx b/tests/keyboard.spec.tsx index ebc271ce0..f96c7b561 100644 --- a/tests/keyboard.spec.tsx +++ b/tests/keyboard.spec.tsx @@ -59,6 +59,62 @@ describe('Picker.Keyboard', () => { expect(onChange).toHaveBeenCalledWith(expect.anything(), '2000-03-03'); }); + it('accepts masked input from Android IME keyboards', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const input = container.querySelector('input'); + + triggerFocus(input); + '20000303'.split('').forEach((key) => { + fireEvent.keyDown(input, { key: 'Unidentified' }); + fireEvent.input(input, { + target: { value: key }, + inputType: 'insertText', + data: key, + }); + }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith(expect.anything(), '20000303'); + }); + + it('accepts masked range input from Android IME keyboards', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const inputs = container.querySelectorAll('input'); + + ['20000303', '20000305'].forEach((value, index) => { + triggerFocus(inputs[index]); + value.split('').forEach((key) => { + fireEvent.keyDown(inputs[index], { key: 'Unidentified' }); + fireEvent.input(inputs[index], { + target: { value: key }, + inputType: 'insertText', + data: key, + }); + }); + fireEvent.keyDown(inputs[index], { key: 'Enter' }); + }); + + expect(onChange).toHaveBeenCalledWith(expect.anything(), ['20000303', '20000305']); + }); + // Coverage case: replace these tests if a clearer interaction can cover the // same behavior. / 覆盖率用例:若有更清晰的交互覆盖相同行为,可直接替换。 it('should submit typed value on Tab without trapping focus', () => {