From 008ac1515100010a8f4c343ab1c13335bef7a042 Mon Sep 17 00:00:00 2001 From: "yangshuning.ysn" Date: Mon, 21 Jul 2025 19:05:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E6=97=A0=E9=9A=9C=E7=A2=8D=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 +- src/components/virtual-input/demos/demo1.tsx | 41 +- .../tests/virtual-input.test.tsx | 490 +++++++++++++++--- .../virtual-input/virtual-input.tsx | 169 +++++- 4 files changed, 598 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index f8f2659f85..d2b932e3bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "antd-mobile", - "version": "5.39.0", + "version": "5.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "antd-mobile", - "version": "5.39.0", + "version": "5.40.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/src/components/virtual-input/demos/demo1.tsx b/src/components/virtual-input/demos/demo1.tsx index 5c598ed28f..ec0ac069d4 100644 --- a/src/components/virtual-input/demos/demo1.tsx +++ b/src/components/virtual-input/demos/demo1.tsx @@ -1,14 +1,20 @@ -import React from 'react' import { NumberKeyboard, VirtualInput } from 'antd-mobile' import { DemoBlock } from 'demos' +import React, { useState } from 'react' + +const TWO_DIGIT_NUMBER_REGEX = /^(([1-9]\d{0,11})|0)(\.\d{0,2}?)?$/ export default () => { + const [value, setValue] = useState('') + const ref = React.useRef(null) return ( <> } + cursor='movable' + ref={ref} + keyboard={} /> @@ -16,10 +22,18 @@ export default () => { } /> + + } + /> + + { style={{ '--caret-width': '1px', '--caret-color': '#666666' }} /> + + + { + if (v.startsWith('.')) { + v = '0' + v + } + v = v.replace(/^0+(\d)/, '$1') + if (TWO_DIGIT_NUMBER_REGEX.test(v) || !v) { + setValue(v) + } + }} + placeholder='请输入内容' + keyboard={} + style={{ + '--font-size': '40px', + }} + /> + + +
) } diff --git a/src/components/virtual-input/tests/virtual-input.test.tsx b/src/components/virtual-input/tests/virtual-input.test.tsx index 34ca98dd90..2ad6669925 100644 --- a/src/components/virtual-input/tests/virtual-input.test.tsx +++ b/src/components/virtual-input/tests/virtual-input.test.tsx @@ -4,6 +4,7 @@ import NumberKeyboard from '../../number-keyboard' import { VirtualInput, VirtualInputRef } from '../virtual-input' const classPrefix = 'adm-virtual-input' +const TWO_DIGIT_NUMBER_REGEX = /^(([1-9]\d{0,11})|0)(\.\d{0,2}?)?$/ function getSiblingElements(element: Element | null) { const prevElements = [], @@ -25,12 +26,85 @@ function getSiblingElements(element: Element | null) { } } +function clickSiblingElements( + element: Element | null, + index: number, + isLeft: boolean +) { + const { prevElements, nextElements } = getSiblingElements(element) + const targetElement = + prevElements[index] || nextElements[index - prevElements.length] + if (targetElement) { + const e = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + Object.defineProperties(e, { + clientX: { value: isLeft ? 102 : 112 }, + }) + + const rect = { + top: 0, + right: 200, + bottom: 20, + left: 100, + x: 100, + y: 0, + width: 20, + height: 20, + toJSON: () => {}, + } + jest.spyOn(targetElement, 'getBoundingClientRect').mockReturnValue(rect) + + targetElement.dispatchEvent(e) + } +} + function getCaretPosition(element: Element | null) { const { prevElements } = getSiblingElements(element) return prevElements.length } +function makeTouchEvent( + type: 'touchmove' | 'touchstart' | 'touchend', + clientX: number +) { + const e = new TouchEvent(type, { + bubbles: true, + cancelable: true, + }) + Object.defineProperty(e, 'touches', { + value: [ + { + clientX, + }, + ], + }) + return e +} + describe('VirtualInput', () => { + beforeEach(() => { + const oldGetBoundingClientRect = Element.prototype.getBoundingClientRect + Element.prototype.getBoundingClientRect = jest.fn(function (this: Element) { + if (this.tagName === 'SPAN') { + return { + width: 10, // 单个字符宽度为 10 + height: 50, + top: 0, + left: 0, + bottom: 0, + right: 200, + x: 0, + y: 0, + toJSON: () => {}, + } + } else { + return oldGetBoundingClientRect.call(this) + } + }) + }) + test('ref should be defined', async () => { const ref = createRef() render() @@ -55,6 +129,8 @@ describe('VirtualInput', () => { act(() => { ref.current?.focus() }) + ref.current?.focus() + expect(document.querySelector(`.${classPrefix}-caret`)).toBeInTheDocument() act(() => { ref.current?.blur() @@ -147,6 +223,7 @@ describe('VirtualInput', () => { } @@ -176,37 +253,8 @@ describe('VirtualInput', () => { expect(getCaretPosition(caretContainer)).toBe(5) // click '3' right side in inputbox, caret position should be 3 - const { prevElements } = getSiblingElements(caretContainer) - if (prevElements[2]) { - const e = new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - Object.defineProperties(e, { - clientX: { value: 118 }, - }) - - const rect = { - top: 0, - right: 200, - bottom: 20, - left: 100, - x: 100, - y: 0, - width: 20, - height: 20, - toJSON: () => {}, - } - jest - .spyOn(prevElements[2], 'getBoundingClientRect') - .mockReturnValue(rect) - - prevElements[2].dispatchEvent(e) - } - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() + await act(() => { + clickSiblingElements(caretContainer, 2, false) }) expect(getCaretPosition(caretContainer)).toBe(3) @@ -243,6 +291,7 @@ describe('VirtualInput', () => { } /> ) @@ -268,46 +317,141 @@ describe('VirtualInput', () => { `.${classPrefix}-caret-container` ) + expect(caretContainer).toBeTruthy() + + expect(getCaretPosition(caretContainer)).toBe(3) + + // click '1' left side in inputbox, caret position should be value end + await act(() => { + clickSiblingElements(caretContainer, 0, true) + }) + expect(getCaretPosition(caretContainer)).toBe(3) + + // click '9' by keyboard, content should be '9123', caret position should be 1 + // click '1' left side in inputbox, caret position should be value end + await act(() => { + clickSiblingElements(caretContainer, 1, true) + }) + fireEvent.touchEnd(screen.getByText('9')) + await waitFor(() => { + expect( + document.querySelector(`.${KeyBoardClassPrefix}-popup`) + ).toBeVisible() + }) + expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent( + '1923' + ) + expect(getCaretPosition(caretContainer)).toBe(2) + + // click delete by keyboard, content should be '123', caret position should be 1 + fireEvent.touchEnd(screen.getByTitle('清除')) + await waitFor(() => { + expect( + document.querySelector(`.${KeyBoardClassPrefix}-popup`) + ).toBeVisible() + }) + expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent( + '123' + ) + expect(getCaretPosition(caretContainer)).toBe(1) + + // click input box, caret position should be 3 (at end) + fireEvent.click(document.querySelector(`.${classPrefix}-content`) as any) + await waitFor(() => { + expect( + document.querySelector(`.${KeyBoardClassPrefix}-popup`) + ).toBeVisible() + }) + expect(getCaretPosition(caretContainer)).toBe(3) + }) + + test('只支持两位金额的受控组件,光标处理正常', async () => { + const KeyBoardClassPrefix = 'adm-number-keyboard' + const Wrapper = () => { + const [value, setValue] = React.useState('0') + return ( + { + if (v.startsWith('.')) { + v = '0' + v + } + v = v.replace(/^0+(\d)/, '$1') + if (TWO_DIGIT_NUMBER_REGEX.test(v) || !v) { + setValue(v) + } + }} + placeholder='请输入内容' + keyboard={} + /> + ) + } + render() + const input = screen.getByTestId('virtualInput') + fireEvent.focus(input) + + await waitFor(() => { + expect( + document.querySelector(`.${KeyBoardClassPrefix}-popup`) + ).toBeVisible() + }) + + // click '1', '0', '3' by keyboard,content should be '103' + fireEvent.touchEnd(screen.getByTitle('1')) + fireEvent.touchEnd(screen.getByTitle('0')) + fireEvent.touchEnd(screen.getByTitle('3')) + expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent( + '103' + ) + const caretContainer = input.querySelector( + `.${classPrefix}-caret-container` + ) + if (caretContainer != null) { expect(getCaretPosition(caretContainer)).toBe(3) - // click '1' left side in inputbox, caret position should be 0 - const { prevElements } = getSiblingElements(caretContainer) - if (prevElements[0]) { - const e = new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - Object.defineProperties(e, { - clientX: { value: 102 }, - }) - - const rect = { - top: 0, - right: 200, - bottom: 20, - left: 100, - x: 100, - y: 0, - width: 20, - height: 20, - toJSON: () => {}, - } - jest - .spyOn(prevElements[0], 'getBoundingClientRect') - .mockReturnValue(rect) + // 输入小数部分 + fireEvent.touchEnd(screen.getByTitle('.')) + fireEvent.touchEnd(screen.getByTitle('4')) + fireEvent.touchEnd(screen.getByTitle('5')) - prevElements[0].dispatchEvent(e) - } - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() + expect( + document.querySelector(`.${classPrefix}-content`) + ).toHaveTextContent('103.45') + expect(getCaretPosition(caretContainer)).toBe(6) + + // 光标移动到 10x3.45, 输入小数点无效 + await act(() => { + clickSiblingElements(caretContainer, 2, true) + }) + expect(getCaretPosition(caretContainer)).toBe(2) + fireEvent.touchEnd(screen.getByTitle('.')) + expect( + document.querySelector(`.${classPrefix}-content`) + ).toHaveTextContent('103.45') + expect(getCaretPosition(caretContainer)).toBe(2) + + // // 光标移动到 x103.45,输入 0 无效 + // await act(() => { + // clickSiblingElements(caretContainer, 0, true) + // }) + // expect(getCaretPosition(caretContainer)).toBe(0) + // fireEvent.touchEnd(screen.getByTitle('.')) + // expect( + // document.querySelector(`.${classPrefix}-content`) + // ).toHaveTextContent('103.45') + // expect(getCaretPosition(caretContainer)).toBe(0) + + // 光标移动到 1x03.45,并删除 1 + await act(() => { + clickSiblingElements(caretContainer, 1, true) }) - expect(getCaretPosition(caretContainer)).toBe(0) + expect(getCaretPosition(caretContainer)).toBe(1) - // click '9' by keyboard, content should be '9123', caret position should be 1 - fireEvent.touchEnd(screen.getByText('9')) + fireEvent.touchEnd(screen.getByTitle('清除')) // 点删除 await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -315,11 +459,16 @@ describe('VirtualInput', () => { }) expect( document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('9123') + ).toHaveTextContent('3.45') + expect(getCaretPosition(caretContainer)).toBe(4) // 变为 3.45 光标到最末尾 + + // 光标移动到 3x.45,并删除 3 + await act(() => { + clickSiblingElements(caretContainer, 1, true) + }) expect(getCaretPosition(caretContainer)).toBe(1) - // click delete by keyboard, content should be '123', caret position should be 1 - fireEvent.touchEnd(screen.getByTitle('清除')) + fireEvent.touchEnd(screen.getByTitle('清除')) // 点删除 await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -327,15 +476,204 @@ describe('VirtualInput', () => { }) expect( document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('123') - expect(getCaretPosition(caretContainer)).toBe(0) + ).toHaveTextContent('0.45') + expect(getCaretPosition(caretContainer)).toBe(4) // 变为 0.45 光标到最末尾 - // click input box, caret position should be 3 - fireEvent.click(document.querySelector(`.${classPrefix}-content`) as any) - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() + // 全部删除,最后为 0 + fireEvent.click(document.querySelector(`.${classPrefix}-clear`) as any) + expect( + document.querySelector(`.${classPrefix}-content`) + ).toHaveTextContent('0') + + fireEvent.touchEnd(screen.getByTitle('9')) // 在 0 时输入 9,则为 9 + expect( + document.querySelector(`.${classPrefix}-content`) + ).toHaveTextContent('9') + expect(getCaretPosition(caretContainer)).toBe(1) + + fireEvent.touchEnd(screen.getByTitle('清除')) // 点删除 + expect( + document.querySelector(`.${classPrefix}-content`) + ).toHaveTextContent('0') + } + }) + + test('caret position should changed by touchmove', async () => { + const KeyBoardClassPrefix = 'adm-number-keyboard' + const Wrapper = () => { + const [value, setValue] = React.useState('0') + return ( + { + if (v.startsWith('.')) { + v = '0' + v + } + v = v.replace(/^0+(\d)/, '$1') + if (TWO_DIGIT_NUMBER_REGEX.test(v) || !v) { + setValue(v) + } + }} + placeholder='请输入内容' + keyboard={} + /> + ) + } + render() + const input = screen.getByTestId('virtualInput') + fireEvent.focus(input) + + await waitFor(() => { + expect( + document.querySelector(`.${KeyBoardClassPrefix}-popup`) + ).toBeVisible() + }) + + const targetElement = input.querySelector(`.${classPrefix}-content`) + expect(targetElement).not.toBeNull() + + // click '1', '2', '3' by keyboard,content should be '123' + fireEvent.touchEnd(screen.getByTitle('1')) + fireEvent.touchEnd(screen.getByTitle('0')) + fireEvent.touchEnd(screen.getByTitle('3')) + fireEvent.touchEnd(screen.getByTitle('.')) + fireEvent.touchEnd(screen.getByTitle('4')) + fireEvent.touchEnd(screen.getByTitle('5')) + expect(targetElement).toHaveTextContent('103.45') + const caretContainer = input.querySelector( + `.${classPrefix}-caret-container` + ) + + expect(caretContainer).toBeTruthy() + expect(getCaretPosition(caretContainer)).toBe(6) + + if (caretContainer && targetElement) { + const rect = { + top: 0, + right: 0, + bottom: 0, + left: 60, // caret 的坐标 + x: 60, + y: 0, + width: 2, + height: 20, + toJSON: () => {}, + } + jest + .spyOn(caretContainer.children[0], 'getBoundingClientRect') + .mockReturnValue(rect) + + // touchstart caret + touchmove 向左 32px + touchmove 向右 18px + await act(() => { + targetElement.dispatchEvent(makeTouchEvent('touchstart', 60)) + targetElement.dispatchEvent(makeTouchEvent('touchmove', 60 - 32)) + }) + expect(getCaretPosition(caretContainer)).toBe(3) // 五入 28/10 -> 3 + await act(() => { + targetElement.dispatchEvent(makeTouchEvent('touchmove', 60 - 32 + 18)) + }) + expect(getCaretPosition(caretContainer)).toBe(5) // 四舍 14/10 -> 1 + // 测试光标闪烁动效,move 中不闪烁,touchend、move 停留超过 500ms 又闪烁 + expect((targetElement.parentNode as Element).classList).toContain( + 'adm-virtual-input-caret-dragging' + ) + await act(() => { + targetElement.dispatchEvent(makeTouchEvent('touchend', 60)) + }) + expect((targetElement.parentNode as Element).classList).not.toContain( + 'adm-virtual-input-caret-dragging' + ) + await act(() => { + targetElement.dispatchEvent(makeTouchEvent('touchstart', 60 - 32 + 18)) + targetElement.dispatchEvent( + makeTouchEvent('touchmove', 60 - 32 + 18 + 1) + ) + }) + expect((targetElement.parentNode as Element).classList).toContain( + 'adm-virtual-input-caret-dragging' + ) + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 600)) + }) + expect((targetElement.parentNode as Element).classList).not.toContain( + 'adm-virtual-input-caret-dragging' + ) + + // 不在 caret 附近 touchstart 则 touchmove 不会改变光标位置 + expect(getCaretPosition(caretContainer)).toBe(5) + await act(() => { + targetElement.dispatchEvent(makeTouchEvent('touchstart', 10)) + targetElement.dispatchEvent(makeTouchEvent('touchmove', 30)) + }) + expect(getCaretPosition(caretContainer)).toBe(5) + } + }) + + test('disable caret position', async () => { + const KeyBoardClassPrefix = 'adm-number-keyboard' + const Wrapper = () => { + return ( + } + /> + ) + } + render() + const input = screen.getByTestId('virtualInput') + fireEvent.focus(input) + + await waitFor(() => { + expect( + document.querySelector(`.${KeyBoardClassPrefix}-popup`) + ).toBeVisible() + }) + + const targetElement = input.querySelector(`.${classPrefix}-content`) + + // click '1', '2', '3' by keyboard,content should be '123' + fireEvent.touchEnd(screen.getByText('1')) + fireEvent.touchEnd(screen.getByText('2')) + fireEvent.touchEnd(screen.getByText('3')) + expect(targetElement).toHaveTextContent('123') + const caretContainer = input.querySelector( + `.${classPrefix}-caret-container` + ) + expect(caretContainer).toBeTruthy() + expect(getCaretPosition(caretContainer)).toBe(3) + + // touchmove 无法改变光标位置 + if (caretContainer && targetElement) { + const rect = { + top: 0, + right: 0, + bottom: 0, + left: 60, // caret 的坐标 + x: 60, + y: 0, + width: 2, + height: 20, + toJSON: () => {}, + } + jest + .spyOn(caretContainer.children[0], 'getBoundingClientRect') + .mockReturnValue(rect) + + await act(() => { + targetElement.dispatchEvent(makeTouchEvent('touchstart', 60)) + targetElement.dispatchEvent(makeTouchEvent('touchmove', 60 - 32)) + }) + + expect(getCaretPosition(caretContainer)).toBe(3) + + // 点击无法改变光标位置 + await act(() => { + clickSiblingElements(caretContainer, 0, true) }) expect(getCaretPosition(caretContainer)).toBe(3) } diff --git a/src/components/virtual-input/virtual-input.tsx b/src/components/virtual-input/virtual-input.tsx index fc18bf9144..873bb204b0 100644 --- a/src/components/virtual-input/virtual-input.tsx +++ b/src/components/virtual-input/virtual-input.tsx @@ -25,6 +25,8 @@ export type VirtualInputProps = { keyboard?: ReactElement clearable?: boolean onClear?: () => void + cursor?: 'static' | 'movable' + onCursorMove?: (position: number) => void } & Pick< InputProps, 'value' | 'onChange' | 'placeholder' | 'disabled' | 'clearIcon' @@ -41,6 +43,8 @@ export type VirtualInputProps = { const defaultProps = { defaultValue: '', + cursor: 'static', + moveToEnd: false, } export type VirtualInputRef = { @@ -57,6 +61,19 @@ export const VirtualInput = forwardRef( const contentRef = useRef(null) const [hasFocus, setHasFocus] = useState(false) const [caretPosition, setCaretPosition] = useState(value.length) // 光标位置,从 0 开始,如值是 2 则表示光标在顺序下标为 2 的数字之前 + const keyboardDataRef = useRef<{ + newValue?: string + mode?: 'input' | 'delete' + }>({}) // 临时记录虚拟键盘输入,在下次更新时用于判断光标位置如何调整 + const touchDataRef = useRef<{ + startX: number + startCaretPosition: number + } | null>() // 记录上一次 touch 时的坐标位置 + const charRef = useRef(null) // 第一个字符的 DOM + const charWidthRef = useRef(0) // 单个字符宽度 + const caretRef = useRef(null) // 光标的 DOM + const [isCaretDragging, setIsCaretDragging] = useState(false) + const touchMoveTimeoutRef = useRef | null>() const clearIcon = mergeProp( , @@ -75,6 +92,26 @@ export const VirtualInput = forwardRef( content.scrollLeft = content.clientWidth } + useEffect(() => { + // 记录单个字符的宽度,用于光标移动时的计算 + if (charRef.current) { + charWidthRef.current = charRef.current.getBoundingClientRect().width + } + }, [value]) + + useEffect(() => { + // 经过外部受控逻辑后,再调整光标位置,如果受控逻辑改动了值则光标放到最后 + if (value === keyboardDataRef.current.newValue) { + if (keyboardDataRef.current.mode === 'input') { + setCaretPosition(c => c + 1) + } else if (keyboardDataRef.current.mode === 'delete') { + setCaretPosition(c => c - 1) + } + } else { + setCaretPosition(value.length) + } + }, [value]) + useIsomorphicLayoutEffect(() => { scrollToEnd() }, [value]) @@ -112,8 +149,9 @@ export const VirtualInput = forwardRef( value.substring(0, caretPosition) + v + value.substring(caretPosition) + // 临时记录,用于后续光标位置 + keyboardDataRef.current = { newValue, mode: 'input' } setValue(newValue) - setCaretPosition((c: number) => c + 1) keyboard.props.onInput?.(v) }, onDelete: () => { @@ -121,8 +159,9 @@ export const VirtualInput = forwardRef( const newValue = value.substring(0, caretPosition - 1) + value.substring(caretPosition) + // 临时记录,用于后续光标位置 + keyboardDataRef.current = { newValue, mode: 'delete' } setValue(newValue) - setCaretPosition(caretPosition - 1) keyboard.props.onDelete?.() }, visible: hasFocus, @@ -144,33 +183,94 @@ export const VirtualInput = forwardRef( // 点击输入框时,将光标置于最后 const setCaretPositionToEnd = () => { - setCaretPosition(value.length) + if (caretPosition !== value.length) { + setCaretPosition(value.length) + mergedProps.onCursorMove?.(value.length) + } } // 点击单个字符时,根据点击位置置于字符前或后 const changeCaretPosition = (index: number) => (e: React.MouseEvent) => { + if (mergedProps.disabled || mergedProps.cursor === 'static') return + if (index === 0) { + setCaretPosition(value.length) + return + } e.stopPropagation() - const rect = (e.target as HTMLElement).getBoundingClientRect() const midX = rect.left + rect.width / 2 const clickX = e.clientX // 点击区域是否偏右 const isRight = clickX > midX - setCaretPosition(isRight ? index + 1 : index) + const newCaretPosition = isRight ? index + 1 : index + setCaretPosition(newCaretPosition) + mergedProps.onCursorMove?.(newCaretPosition) } - const chars = (value + '').split('') + // 在光标附近 touchmove 时也可以调整光标位置 + const handleTouchStart = (e: React.TouchEvent) => { + if (mergedProps.disabled || mergedProps.cursor === 'static') return + if (!caretRef.current) return + + const touch = e.touches[0] + const caretRect = caretRef.current.getBoundingClientRect() + const distance = Math.abs( + touch.clientX - (caretRect.left + caretRect.width / 2) + ) + if (distance < 20) { + // 20px 阈值可调整 + touchDataRef.current = { + startX: touch.clientX, + startCaretPosition: caretPosition, + } + } else { + touchDataRef.current = null + } + } + + const handleTouchMove = (e: React.TouchEvent) => { + if (!touchDataRef.current || mergedProps.cursor === 'static') return + + setIsCaretDragging(true) + const touch = e.touches[0] + const deltaX = touch.clientX - touchDataRef.current.startX + + const charWidth = charWidthRef.current + const moveChars = Math.round(deltaX / charWidth) + let newCaretPosition = touchDataRef.current.startCaretPosition + moveChars + // 边界处理 + newCaretPosition = Math.max(0, Math.min(newCaretPosition, value.length)) + setCaretPosition(newCaretPosition) + mergedProps.onCursorMove?.(newCaretPosition) + + // 防止 touchend 不触发 + if (touchMoveTimeoutRef.current) { + clearTimeout(touchMoveTimeoutRef.current) + } + touchMoveTimeoutRef.current = setTimeout(() => { + setIsCaretDragging(false) + touchMoveTimeoutRef.current = null + }, 500) + } + + const handleTouchEnd = () => { + touchDataRef.current = null + setIsCaretDragging(false) + } + + const chars = (value + '').split('') return withNativeProps( mergedProps,
(
{chars.slice(0, caretPosition).map((i: string, index: number) => ( - + {i} ))} -
- {hasFocus &&
} -
+ {/* 只有聚焦时才改变光标前后距离的样式 */} + {hasFocus && ( +
+
+
+ )} {chars.slice(caretPosition).map((i: string, index: number) => ( ( ))}
- {mergedProps.clearable && !!value && hasFocus && ( -
{ - e.stopPropagation() - setValue('') - setCaretPosition(0) - mergedProps.onClear?.() - }} - role='button' - aria-label={locale.Input.clear} - > - {clearIcon} -
- )} + {mergedProps.clearable && + !!value && + hasFocus && + !mergedProps.disabled && ( +
{ + e.stopPropagation() + setValue('') + mergedProps.onClear?.() + }} + onMouseDown={e => { + e.preventDefault() + }} + role='button' + aria-label={locale.Input.clear} + > + {clearIcon} +
+ )} {[undefined, null, ''].includes(value) && (
{mergedProps.placeholder} From c137f6fcf666b1f060d72f2f2865f2199bef593f Mon Sep 17 00:00:00 2001 From: "yangshuning.ysn" Date: Mon, 21 Jul 2025 19:29:47 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E6=B3=A8=E9=87=8A=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/virtual-input.test.tsx | 43 +++++++------------ .../virtual-input/virtual-input.tsx | 36 ++++++++-------- 2 files changed, 34 insertions(+), 45 deletions(-) diff --git a/src/components/virtual-input/tests/virtual-input.test.tsx b/src/components/virtual-input/tests/virtual-input.test.tsx index 2ad6669925..5f7abf0d18 100644 --- a/src/components/virtual-input/tests/virtual-input.test.tsx +++ b/src/components/virtual-input/tests/virtual-input.test.tsx @@ -89,7 +89,7 @@ describe('VirtualInput', () => { Element.prototype.getBoundingClientRect = jest.fn(function (this: Element) { if (this.tagName === 'SPAN') { return { - width: 10, // 单个字符宽度为 10 + width: 10, // Single character width is 10 height: 50, top: 0, left: 0, @@ -365,7 +365,7 @@ describe('VirtualInput', () => { expect(getCaretPosition(caretContainer)).toBe(3) }) - test('只支持两位金额的受控组件,光标处理正常', async () => { + test('Controlled component with 2-digit decimal support should handle cursor correctly', async () => { const KeyBoardClassPrefix = 'adm-number-keyboard' const Wrapper = () => { const [value, setValue] = React.useState('0') @@ -413,7 +413,7 @@ describe('VirtualInput', () => { if (caretContainer != null) { expect(getCaretPosition(caretContainer)).toBe(3) - // 输入小数部分 + // Input decimal part fireEvent.touchEnd(screen.getByTitle('.')) fireEvent.touchEnd(screen.getByTitle('4')) fireEvent.touchEnd(screen.getByTitle('5')) @@ -423,7 +423,7 @@ describe('VirtualInput', () => { ).toHaveTextContent('103.45') expect(getCaretPosition(caretContainer)).toBe(6) - // 光标移动到 10x3.45, 输入小数点无效 + // Move cursor to 10x3.45, decimal input should be invalid await act(() => { clickSiblingElements(caretContainer, 2, true) }) @@ -434,24 +434,13 @@ describe('VirtualInput', () => { ).toHaveTextContent('103.45') expect(getCaretPosition(caretContainer)).toBe(2) - // // 光标移动到 x103.45,输入 0 无效 - // await act(() => { - // clickSiblingElements(caretContainer, 0, true) - // }) - // expect(getCaretPosition(caretContainer)).toBe(0) - // fireEvent.touchEnd(screen.getByTitle('.')) - // expect( - // document.querySelector(`.${classPrefix}-content`) - // ).toHaveTextContent('103.45') - // expect(getCaretPosition(caretContainer)).toBe(0) - - // 光标移动到 1x03.45,并删除 1 + // Move cursor to 1x03.45 and delete 1 await act(() => { clickSiblingElements(caretContainer, 1, true) }) expect(getCaretPosition(caretContainer)).toBe(1) - fireEvent.touchEnd(screen.getByTitle('清除')) // 点删除 + fireEvent.touchEnd(screen.getByTitle('清除')) // Click delete await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -460,9 +449,9 @@ describe('VirtualInput', () => { expect( document.querySelector(`.${classPrefix}-content`) ).toHaveTextContent('3.45') - expect(getCaretPosition(caretContainer)).toBe(4) // 变为 3.45 光标到最末尾 + expect(getCaretPosition(caretContainer)).toBe(4) // Change to 3.45 with cursor at end - // 光标移动到 3x.45,并删除 3 + // Move cursor to 3x.45 and delete 3 await act(() => { clickSiblingElements(caretContainer, 1, true) }) @@ -477,15 +466,15 @@ describe('VirtualInput', () => { expect( document.querySelector(`.${classPrefix}-content`) ).toHaveTextContent('0.45') - expect(getCaretPosition(caretContainer)).toBe(4) // 变为 0.45 光标到最末尾 + expect(getCaretPosition(caretContainer)).toBe(4) // Change to 0.45 with cursor at end - // 全部删除,最后为 0 + // Delete all, result should be 0 fireEvent.click(document.querySelector(`.${classPrefix}-clear`) as any) expect( document.querySelector(`.${classPrefix}-content`) ).toHaveTextContent('0') - fireEvent.touchEnd(screen.getByTitle('9')) // 在 0 时输入 9,则为 9 + fireEvent.touchEnd(screen.getByTitle('9')) // When input is 0, typing 9 should result in 9 expect( document.querySelector(`.${classPrefix}-content`) ).toHaveTextContent('9') @@ -571,12 +560,12 @@ describe('VirtualInput', () => { targetElement.dispatchEvent(makeTouchEvent('touchstart', 60)) targetElement.dispatchEvent(makeTouchEvent('touchmove', 60 - 32)) }) - expect(getCaretPosition(caretContainer)).toBe(3) // 五入 28/10 -> 3 + expect(getCaretPosition(caretContainer)).toBe(3) // Round up 28/10 -> 3 await act(() => { targetElement.dispatchEvent(makeTouchEvent('touchmove', 60 - 32 + 18)) }) - expect(getCaretPosition(caretContainer)).toBe(5) // 四舍 14/10 -> 1 - // 测试光标闪烁动效,move 中不闪烁,touchend、move 停留超过 500ms 又闪烁 + expect(getCaretPosition(caretContainer)).toBe(5) // Round down 14/10 -> 1 + // Test cursor blinking effect: no blinking during move, blinking resumes after touchend or when move stays over 500ms expect((targetElement.parentNode as Element).classList).toContain( 'adm-virtual-input-caret-dragging' ) @@ -602,7 +591,7 @@ describe('VirtualInput', () => { 'adm-virtual-input-caret-dragging' ) - // 不在 caret 附近 touchstart 则 touchmove 不会改变光标位置 + // If touchstart is not near caret, touchmove won't change cursor position expect(getCaretPosition(caretContainer)).toBe(5) await act(() => { targetElement.dispatchEvent(makeTouchEvent('touchstart', 10)) @@ -647,7 +636,7 @@ describe('VirtualInput', () => { expect(caretContainer).toBeTruthy() expect(getCaretPosition(caretContainer)).toBe(3) - // touchmove 无法改变光标位置 + // touchmove无法改变光标位置 if (caretContainer && targetElement) { const rect = { top: 0, diff --git a/src/components/virtual-input/virtual-input.tsx b/src/components/virtual-input/virtual-input.tsx index 873bb204b0..6e92968496 100644 --- a/src/components/virtual-input/virtual-input.tsx +++ b/src/components/virtual-input/virtual-input.tsx @@ -60,18 +60,18 @@ export const VirtualInput = forwardRef( const rootRef = useRef(null) const contentRef = useRef(null) const [hasFocus, setHasFocus] = useState(false) - const [caretPosition, setCaretPosition] = useState(value.length) // 光标位置,从 0 开始,如值是 2 则表示光标在顺序下标为 2 的数字之前 + const [caretPosition, setCaretPosition] = useState(value.length) // Cursor position starting from 0, e.g. value 2 means cursor is before the character at index 2 const keyboardDataRef = useRef<{ newValue?: string mode?: 'input' | 'delete' - }>({}) // 临时记录虚拟键盘输入,在下次更新时用于判断光标位置如何调整 + }>({}) // Temporarily store virtual keyboard input to determine cursor position adjustment in next update const touchDataRef = useRef<{ startX: number startCaretPosition: number - } | null>() // 记录上一次 touch 时的坐标位置 - const charRef = useRef(null) // 第一个字符的 DOM - const charWidthRef = useRef(0) // 单个字符宽度 - const caretRef = useRef(null) // 光标的 DOM + } | null>() // Record last touch position coordinates + const charRef = useRef(null) // DOM reference of first character + const charWidthRef = useRef(0) // Width of single character + const caretRef = useRef(null) // DOM reference of cursor const [isCaretDragging, setIsCaretDragging] = useState(false) const touchMoveTimeoutRef = useRef | null>() @@ -93,14 +93,14 @@ export const VirtualInput = forwardRef( } useEffect(() => { - // 记录单个字符的宽度,用于光标移动时的计算 + // Measure single character width for cursor movement calculation if (charRef.current) { charWidthRef.current = charRef.current.getBoundingClientRect().width } }, [value]) useEffect(() => { - // 经过外部受控逻辑后,再调整光标位置,如果受控逻辑改动了值则光标放到最后 + // After controlled logic, adjust cursor position - move to end if value was modified if (value === keyboardDataRef.current.newValue) { if (keyboardDataRef.current.mode === 'input') { setCaretPosition(c => c + 1) @@ -149,7 +149,7 @@ export const VirtualInput = forwardRef( value.substring(0, caretPosition) + v + value.substring(caretPosition) - // 临时记录,用于后续光标位置 + // Temporarily store for subsequent cursor positioning keyboardDataRef.current = { newValue, mode: 'input' } setValue(newValue) keyboard.props.onInput?.(v) @@ -159,7 +159,7 @@ export const VirtualInput = forwardRef( const newValue = value.substring(0, caretPosition - 1) + value.substring(caretPosition) - // 临时记录,用于后续光标位置 + // Temporarily store for subsequent cursor positioning keyboardDataRef.current = { newValue, mode: 'delete' } setValue(newValue) keyboard.props.onDelete?.() @@ -181,7 +181,7 @@ export const VirtualInput = forwardRef( getContainer: null, } as NumberKeyboardProps) - // 点击输入框时,将光标置于最后 + // When clicking input box, place cursor at end const setCaretPositionToEnd = () => { if (caretPosition !== value.length) { setCaretPosition(value.length) @@ -189,7 +189,7 @@ export const VirtualInput = forwardRef( } } - // 点击单个字符时,根据点击位置置于字符前或后 + // When clicking character, position cursor before or after based on click position const changeCaretPosition = (index: number) => (e: React.MouseEvent) => { if (mergedProps.disabled || mergedProps.cursor === 'static') return if (index === 0) { @@ -200,7 +200,7 @@ export const VirtualInput = forwardRef( const rect = (e.target as HTMLElement).getBoundingClientRect() const midX = rect.left + rect.width / 2 const clickX = e.clientX - // 点击区域是否偏右 + // Check if click area is right-biased const isRight = clickX > midX const newCaretPosition = isRight ? index + 1 : index @@ -208,7 +208,7 @@ export const VirtualInput = forwardRef( mergedProps.onCursorMove?.(newCaretPosition) } - // 在光标附近 touchmove 时也可以调整光标位置 + // Adjust cursor position when touchmoving near caret const handleTouchStart = (e: React.TouchEvent) => { if (mergedProps.disabled || mergedProps.cursor === 'static') return if (!caretRef.current) return @@ -219,7 +219,7 @@ export const VirtualInput = forwardRef( touch.clientX - (caretRect.left + caretRect.width / 2) ) if (distance < 20) { - // 20px 阈值可调整 + // 20px threshold is adjustable touchDataRef.current = { startX: touch.clientX, startCaretPosition: caretPosition, @@ -240,12 +240,12 @@ export const VirtualInput = forwardRef( const charWidth = charWidthRef.current const moveChars = Math.round(deltaX / charWidth) let newCaretPosition = touchDataRef.current.startCaretPosition + moveChars - // 边界处理 + // Boundary handling newCaretPosition = Math.max(0, Math.min(newCaretPosition, value.length)) setCaretPosition(newCaretPosition) mergedProps.onCursorMove?.(newCaretPosition) - // 防止 touchend 不触发 + // Prevent missing touchend event if (touchMoveTimeoutRef.current) { clearTimeout(touchMoveTimeoutRef.current) } @@ -296,7 +296,7 @@ export const VirtualInput = forwardRef( {i} ))} - {/* 只有聚焦时才改变光标前后距离的样式 */} + {/* Only change cursor spacing style when focused */} {hasFocus && (
From fd3ef01b3faafd5b18059d6e8ab61c049a762b49 Mon Sep 17 00:00:00 2001 From: "yangshuning.ysn" Date: Tue, 22 Jul 2025 17:37:07 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E6=97=A0=E9=9A=9C=E7=A2=8D=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/virtual-input/demos/demo1.tsx | 8 - .../tests/virtual-input.test.tsx | 420 ++---------------- .../virtual-input/virtual-input.tsx | 153 +------ 3 files changed, 50 insertions(+), 531 deletions(-) diff --git a/src/components/virtual-input/demos/demo1.tsx b/src/components/virtual-input/demos/demo1.tsx index eaaa7cc390..b154a761e9 100644 --- a/src/components/virtual-input/demos/demo1.tsx +++ b/src/components/virtual-input/demos/demo1.tsx @@ -13,7 +13,6 @@ export default () => { } /> @@ -23,7 +22,6 @@ export default () => { } /> @@ -62,7 +60,6 @@ export default () => { { if (v.startsWith('.')) { v = '0' + v @@ -81,11 +78,6 @@ export default () => {
- keyboard={} - /> - - -
) } diff --git a/src/components/virtual-input/tests/virtual-input.test.tsx b/src/components/virtual-input/tests/virtual-input.test.tsx index e6885ff186..3fecd5bed1 100644 --- a/src/components/virtual-input/tests/virtual-input.test.tsx +++ b/src/components/virtual-input/tests/virtual-input.test.tsx @@ -65,46 +65,7 @@ function getCaretPosition(element: Element | null) { return prevElements.length } -function makeTouchEvent( - type: 'touchmove' | 'touchstart' | 'touchend', - clientX: number -) { - const e = new TouchEvent(type, { - bubbles: true, - cancelable: true, - }) - Object.defineProperty(e, 'touches', { - value: [ - { - clientX, - }, - ], - }) - return e -} - describe('VirtualInput', () => { - beforeEach(() => { - const oldGetBoundingClientRect = Element.prototype.getBoundingClientRect - Element.prototype.getBoundingClientRect = jest.fn(function (this: Element) { - if (this.tagName === 'SPAN') { - return { - width: 10, // Single character width is 10 - height: 50, - top: 0, - left: 0, - bottom: 0, - right: 200, - x: 0, - y: 0, - toJSON: () => {}, - } - } else { - return oldGetBoundingClientRect.call(this) - } - }) - }) - test('ref should be defined', async () => { const ref = createRef() render() @@ -129,8 +90,6 @@ describe('VirtualInput', () => { act(() => { ref.current?.focus() }) - ref.current?.focus() - expect(document.querySelector(`.${classPrefix}-caret`)).toBeInTheDocument() act(() => { ref.current?.blur() @@ -223,7 +182,6 @@ describe('VirtualInput', () => { } @@ -253,7 +211,6 @@ describe('VirtualInput', () => { expect(getCaretPosition(caretContainer)).toBe(5) // click '3' right side in inputbox, caret position should be 3 - clickSiblingElements(caretContainer, 2, false) await waitFor(() => { expect( @@ -295,7 +252,6 @@ describe('VirtualInput', () => { } /> ) @@ -321,132 +277,20 @@ describe('VirtualInput', () => { `.${classPrefix}-caret-container` ) - expect(caretContainer).toBeTruthy() - - expect(getCaretPosition(caretContainer)).toBe(3) - - // click '1' left side in inputbox, caret position should be value end - await act(() => { - clickSiblingElements(caretContainer, 0, true) - }) - expect(getCaretPosition(caretContainer)).toBe(3) - - // click '9' by keyboard, content should be '9123', caret position should be 1 - // click '1' left side in inputbox, caret position should be value end - await act(() => { - clickSiblingElements(caretContainer, 1, true) - }) - fireEvent.touchEnd(screen.getByText('9')) - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() - }) - expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent( - '1923' - ) - expect(getCaretPosition(caretContainer)).toBe(2) - - // click delete by keyboard, content should be '123', caret position should be 1 - fireEvent.touchEnd(screen.getByTitle('清除')) - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() - }) - expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent( - '123' - ) - expect(getCaretPosition(caretContainer)).toBe(1) - - // click input box, caret position should be 3 (at end) - fireEvent.click(document.querySelector(`.${classPrefix}-content`) as any) - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() - }) - expect(getCaretPosition(caretContainer)).toBe(3) - }) - - test('Controlled component with 2-digit decimal support should handle cursor correctly', async () => { - const KeyBoardClassPrefix = 'adm-number-keyboard' - const Wrapper = () => { - const [value, setValue] = React.useState('0') - return ( - { - if (v.startsWith('.')) { - v = '0' + v - } - v = v.replace(/^0+(\d)/, '$1') - if (TWO_DIGIT_NUMBER_REGEX.test(v) || !v) { - setValue(v) - } - }} - placeholder='请输入内容' - keyboard={} - /> - ) - } - render() - const input = screen.getByTestId('virtualInput') - fireEvent.focus(input) - - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() - }) - - // click '1', '0', '3' by keyboard,content should be '103' - fireEvent.touchEnd(screen.getByTitle('1')) - fireEvent.touchEnd(screen.getByTitle('0')) - fireEvent.touchEnd(screen.getByTitle('3')) - expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent( - '103' - ) - const caretContainer = input.querySelector( - `.${classPrefix}-caret-container` - ) - if (caretContainer != null) { expect(getCaretPosition(caretContainer)).toBe(3) - // Input decimal part - fireEvent.touchEnd(screen.getByTitle('.')) - fireEvent.touchEnd(screen.getByTitle('4')) - fireEvent.touchEnd(screen.getByTitle('5')) - expect( - document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('103.45') - expect(getCaretPosition(caretContainer)).toBe(6) - - // click '1' left side in inputbox, caret position should be 0 + // click '1' left side in inputbox, caret position should move to end clickSiblingElements(caretContainer, 0, true) await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) ).toBeVisible() }) - expect(getCaretPosition(caretContainer)).toBe(2) - fireEvent.touchEnd(screen.getByTitle('.')) - expect( - document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('103.45') - expect(getCaretPosition(caretContainer)).toBe(2) - - // Move cursor to 1x03.45 and delete 1 - await act(() => { - clickSiblingElements(caretContainer, 1, true) - }) - expect(getCaretPosition(caretContainer)).toBe(1) + expect(getCaretPosition(caretContainer)).toBe(3) - fireEvent.touchEnd(screen.getByTitle('清除')) // Click delete + // click '9' by keyboard, content should be '1239', caret position should be ended + fireEvent.touchEnd(screen.getByText('9')) await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -454,16 +298,11 @@ describe('VirtualInput', () => { }) expect( document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('3.45') - expect(getCaretPosition(caretContainer)).toBe(4) // Change to 3.45 with cursor at end - - // Move cursor to 3x.45 and delete 3 - await act(() => { - clickSiblingElements(caretContainer, 1, true) - }) - expect(getCaretPosition(caretContainer)).toBe(1) + ).toHaveTextContent('1239') + expect(getCaretPosition(caretContainer)).toBe(4) - fireEvent.touchEnd(screen.getByTitle('清除')) // 点删除 + // click delete by keyboard, content should be '123', caret position should be 1 + fireEvent.touchEnd(screen.getByTitle('清除')) await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -471,210 +310,21 @@ describe('VirtualInput', () => { }) expect( document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('0.45') - expect(getCaretPosition(caretContainer)).toBe(4) // Change to 0.45 with cursor at end - - // Delete all, result should be 0 - fireEvent.click(document.querySelector(`.${classPrefix}-clear`) as any) - expect( - document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('0') - - fireEvent.touchEnd(screen.getByTitle('9')) // When input is 0, typing 9 should result in 9 - expect( - document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('9') - expect(getCaretPosition(caretContainer)).toBe(1) - - fireEvent.touchEnd(screen.getByTitle('清除')) // 点删除 - expect( - document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('0') - } - }) - - test('caret position should changed by touchmove', async () => { - const KeyBoardClassPrefix = 'adm-number-keyboard' - const Wrapper = () => { - const [value, setValue] = React.useState('0') - return ( - { - if (v.startsWith('.')) { - v = '0' + v - } - v = v.replace(/^0+(\d)/, '$1') - if (TWO_DIGIT_NUMBER_REGEX.test(v) || !v) { - setValue(v) - } - }} - placeholder='请输入内容' - keyboard={} - /> - ) - } - render() - const input = screen.getByTestId('virtualInput') - fireEvent.focus(input) - - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() - }) - - const targetElement = input.querySelector(`.${classPrefix}-content`) - expect(targetElement).not.toBeNull() - - // click '1', '2', '3' by keyboard,content should be '123' - fireEvent.touchEnd(screen.getByTitle('1')) - fireEvent.touchEnd(screen.getByTitle('0')) - fireEvent.touchEnd(screen.getByTitle('3')) - fireEvent.touchEnd(screen.getByTitle('.')) - fireEvent.touchEnd(screen.getByTitle('4')) - fireEvent.touchEnd(screen.getByTitle('5')) - expect(targetElement).toHaveTextContent('103.45') - const caretContainer = input.querySelector( - `.${classPrefix}-caret-container` - ) - - expect(caretContainer).toBeTruthy() - expect(getCaretPosition(caretContainer)).toBe(6) - - if (caretContainer && targetElement) { - const rect = { - top: 0, - right: 0, - bottom: 0, - left: 60, // caret 的坐标 - x: 60, - y: 0, - width: 2, - height: 20, - toJSON: () => {}, - } - jest - .spyOn(caretContainer.children[0], 'getBoundingClientRect') - .mockReturnValue(rect) - - // touchstart caret + touchmove 向左 32px + touchmove 向右 18px - await act(() => { - targetElement.dispatchEvent(makeTouchEvent('touchstart', 60)) - targetElement.dispatchEvent(makeTouchEvent('touchmove', 60 - 32)) - }) - expect(getCaretPosition(caretContainer)).toBe(3) // Round up 28/10 -> 3 - await act(() => { - targetElement.dispatchEvent(makeTouchEvent('touchmove', 60 - 32 + 18)) - }) - expect(getCaretPosition(caretContainer)).toBe(5) // Round down 14/10 -> 1 - // Test cursor blinking effect: no blinking during move, blinking resumes after touchend or when move stays over 500ms - expect((targetElement.parentNode as Element).classList).toContain( - 'adm-virtual-input-caret-dragging' - ) - await act(() => { - targetElement.dispatchEvent(makeTouchEvent('touchend', 60)) - }) - expect((targetElement.parentNode as Element).classList).not.toContain( - 'adm-virtual-input-caret-dragging' - ) - await act(() => { - targetElement.dispatchEvent(makeTouchEvent('touchstart', 60 - 32 + 18)) - targetElement.dispatchEvent( - makeTouchEvent('touchmove', 60 - 32 + 18 + 1) - ) - }) - expect((targetElement.parentNode as Element).classList).toContain( - 'adm-virtual-input-caret-dragging' - ) - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 600)) - }) - expect((targetElement.parentNode as Element).classList).not.toContain( - 'adm-virtual-input-caret-dragging' - ) - - // If touchstart is not near caret, touchmove won't change cursor position - expect(getCaretPosition(caretContainer)).toBe(5) - await act(() => { - targetElement.dispatchEvent(makeTouchEvent('touchstart', 10)) - targetElement.dispatchEvent(makeTouchEvent('touchmove', 30)) - }) - expect(getCaretPosition(caretContainer)).toBe(5) - } - }) - - test('disable caret position', async () => { - const KeyBoardClassPrefix = 'adm-number-keyboard' - const Wrapper = () => { - return ( - } - /> - ) - } - render() - const input = screen.getByTestId('virtualInput') - fireEvent.focus(input) - - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() - }) - - const targetElement = input.querySelector(`.${classPrefix}-content`) - - // click '1', '2', '3' by keyboard,content should be '123' - fireEvent.touchEnd(screen.getByText('1')) - fireEvent.touchEnd(screen.getByText('2')) - fireEvent.touchEnd(screen.getByText('3')) - expect(targetElement).toHaveTextContent('123') - const caretContainer = input.querySelector( - `.${classPrefix}-caret-container` - ) - expect(caretContainer).toBeTruthy() - expect(getCaretPosition(caretContainer)).toBe(3) - - // touchmove无法改变光标位置 - if (caretContainer && targetElement) { - const rect = { - top: 0, - right: 0, - bottom: 0, - left: 60, // caret 的坐标 - x: 60, - y: 0, - width: 2, - height: 20, - toJSON: () => {}, - } - jest - .spyOn(caretContainer.children[0], 'getBoundingClientRect') - .mockReturnValue(rect) - - await act(() => { - targetElement.dispatchEvent(makeTouchEvent('touchstart', 60)) - targetElement.dispatchEvent(makeTouchEvent('touchmove', 60 - 32)) - }) - + ).toHaveTextContent('123') expect(getCaretPosition(caretContainer)).toBe(3) - // 点击无法改变光标位置 - await act(() => { - clickSiblingElements(caretContainer, 0, true) + // click input box, caret position should be 3 + fireEvent.click(document.querySelector(`.${classPrefix}-content`) as any) + await waitFor(() => { + expect( + document.querySelector(`.${KeyBoardClassPrefix}-popup`) + ).toBeVisible() }) expect(getCaretPosition(caretContainer)).toBe(3) } }) - test('只支持两位金额的受控组件,光标处理正常', async () => { + test('controlled component with 2-digit decimal amount should handle cursor correctly', async () => { const KeyBoardClassPrefix = 'adm-number-keyboard' const Wrapper = () => { const [value, setValue] = React.useState('0') @@ -721,7 +371,7 @@ describe('VirtualInput', () => { if (caretContainer != null) { expect(getCaretPosition(caretContainer)).toBe(3) - // 输入小数部分 + // Input decimal part fireEvent.touchEnd(screen.getByTitle('.')) fireEvent.touchEnd(screen.getByTitle('4')) fireEvent.touchEnd(screen.getByTitle('5')) @@ -731,7 +381,7 @@ describe('VirtualInput', () => { ).toHaveTextContent('103.45') expect(getCaretPosition(caretContainer)).toBe(6) - // 光标移动到 10x3.45, 输入小数点无效 + // Move cursor to between 10 and 3.45, decimal input should be invalid clickSiblingElements(caretContainer, 2, true) await waitFor(() => { expect( @@ -745,22 +395,8 @@ describe('VirtualInput', () => { ).toHaveTextContent('103.45') expect(getCaretPosition(caretContainer)).toBe(2) - // 光标移动到 x103.45,输入 0 无效 - clickSiblingElements(caretContainer, 0, true) - await waitFor(() => { - expect( - document.querySelector(`.${KeyBoardClassPrefix}-popup`) - ).toBeVisible() - }) - expect(getCaretPosition(caretContainer)).toBe(0) - fireEvent.touchEnd(screen.getByTitle('.')) - expect( - document.querySelector(`.${classPrefix}-content`) - ).toHaveTextContent('103.45') - expect(getCaretPosition(caretContainer)).toBe(0) - - // 光标移动到 1x03.45,并删除 1 - clickSiblingElements(caretContainer, 0, false) + // Move cursor between 1 and 03.45, then delete 1 + clickSiblingElements(caretContainer, 1, true) await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -768,7 +404,7 @@ describe('VirtualInput', () => { }) expect(getCaretPosition(caretContainer)).toBe(1) - fireEvent.touchEnd(screen.getByTitle('清除')) // 点删除 + fireEvent.touchEnd(screen.getByTitle('清除')) // Click delete await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -777,10 +413,10 @@ describe('VirtualInput', () => { expect( document.querySelector(`.${classPrefix}-content`) ).toHaveTextContent('3.45') - expect(getCaretPosition(caretContainer)).toBe(4) // 变为 3.45 光标到最末尾 + expect(getCaretPosition(caretContainer)).toBe(4) // Value becomes 3.45 with cursor at end - // 光标移动到 3x.45,并删除 3 - clickSiblingElements(caretContainer, 0, false) + // Move cursor between 3 and .45, then delete 3 + clickSiblingElements(caretContainer, 1, true) await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -788,7 +424,7 @@ describe('VirtualInput', () => { }) expect(getCaretPosition(caretContainer)).toBe(1) - fireEvent.touchEnd(screen.getByTitle('清除')) // 点删除 + fireEvent.touchEnd(screen.getByTitle('清除')) // Click delete await waitFor(() => { expect( document.querySelector(`.${KeyBoardClassPrefix}-popup`) @@ -797,15 +433,15 @@ describe('VirtualInput', () => { expect( document.querySelector(`.${classPrefix}-content`) ).toHaveTextContent('0.45') - expect(getCaretPosition(caretContainer)).toBe(4) // 变为 0.45 光标到最末尾 + expect(getCaretPosition(caretContainer)).toBe(4) // Value becomes 0.45 with cursor at end - // 全部删除,最后为 0 + // Delete all, value becomes 0 fireEvent.click(document.querySelector(`.${classPrefix}-clear`) as any) expect( document.querySelector(`.${classPrefix}-content`) ).toHaveTextContent('0') - fireEvent.touchEnd(screen.getByTitle('9')) // 在 0 时输入 9,则为 9 + fireEvent.touchEnd(screen.getByTitle('9')) // When value is 0, input 9 becomes 9 expect( document.querySelector(`.${classPrefix}-content`) ).toHaveTextContent('9') diff --git a/src/components/virtual-input/virtual-input.tsx b/src/components/virtual-input/virtual-input.tsx index e893647f84..24a328d64b 100644 --- a/src/components/virtual-input/virtual-input.tsx +++ b/src/components/virtual-input/virtual-input.tsx @@ -25,8 +25,6 @@ export type VirtualInputProps = { keyboard?: ReactElement clearable?: boolean onClear?: () => void - cursor?: 'static' | 'movable' - onCursorMove?: (position: number) => void } & Pick< InputProps, 'value' | 'onChange' | 'placeholder' | 'disabled' | 'clearIcon' @@ -43,8 +41,6 @@ export type VirtualInputProps = { const defaultProps = { defaultValue: '', - cursor: 'static', - moveToEnd: false, } export type VirtualInputRef = { @@ -64,20 +60,7 @@ export const VirtualInput = forwardRef( mode?: 'input' | 'delete' }>({}) const [hasFocus, setHasFocus] = useState(false) - const [caretPosition, setCaretPosition] = useState(value.length) // Cursor position starting from 0, e.g. value 2 means cursor is before the character at index 2 - const keyboardDataRef = useRef<{ - newValue?: string - mode?: 'input' | 'delete' - }>({}) // Temporarily store virtual keyboard input to determine cursor position adjustment in next update - const touchDataRef = useRef<{ - startX: number - startCaretPosition: number - } | null>() // Record last touch position coordinates - const charRef = useRef(null) // DOM reference of first character - const charWidthRef = useRef(0) // Width of single character - const caretRef = useRef(null) // DOM reference of cursor - const [isCaretDragging, setIsCaretDragging] = useState(false) - const touchMoveTimeoutRef = useRef | null>() + const [caretPosition, setCaretPosition] = useState(value.length) // 光标位置,从 0 开始,如值是 2 则表示光标在顺序下标为 2 的数字之前 useEffect(() => { if (value === keyboardDataRef.current.newValue) { @@ -108,26 +91,6 @@ export const VirtualInput = forwardRef( content.scrollLeft = content.clientWidth } - useEffect(() => { - // Measure single character width for cursor movement calculation - if (charRef.current) { - charWidthRef.current = charRef.current.getBoundingClientRect().width - } - }, [value]) - - useEffect(() => { - // After controlled logic, adjust cursor position - move to end if value was modified - if (value === keyboardDataRef.current.newValue) { - if (keyboardDataRef.current.mode === 'input') { - setCaretPosition(c => c + 1) - } else if (keyboardDataRef.current.mode === 'delete') { - setCaretPosition(c => c - 1) - } - } else { - setCaretPosition(value.length) - } - }, [value]) - useIsomorphicLayoutEffect(() => { scrollToEnd() }, [value]) @@ -199,91 +162,33 @@ export const VirtualInput = forwardRef( // When clicking input box, place cursor at end const setCaretPositionToEnd = () => { - if (caretPosition !== value.length) { - setCaretPosition(value.length) - mergedProps.onCursorMove?.(value.length) - } + setCaretPosition(value.length) } // When clicking character, position cursor before or after based on click position const changeCaretPosition = (index: number) => (e: React.MouseEvent) => { - if (mergedProps.disabled || mergedProps.cursor === 'static') return + e.stopPropagation() if (index === 0) { setCaretPosition(value.length) return } - e.stopPropagation() const rect = (e.target as HTMLElement).getBoundingClientRect() const midX = rect.left + rect.width / 2 const clickX = e.clientX // Check if click area is right-biased const isRight = clickX > midX - const newCaretPosition = isRight ? index + 1 : index - setCaretPosition(newCaretPosition) - mergedProps.onCursorMove?.(newCaretPosition) - } - - // Adjust cursor position when touchmoving near caret - const handleTouchStart = (e: React.TouchEvent) => { - if (mergedProps.disabled || mergedProps.cursor === 'static') return - if (!caretRef.current) return - - const touch = e.touches[0] - const caretRect = caretRef.current.getBoundingClientRect() - const distance = Math.abs( - touch.clientX - (caretRect.left + caretRect.width / 2) - ) - if (distance < 20) { - // 20px threshold is adjustable - touchDataRef.current = { - startX: touch.clientX, - startCaretPosition: caretPosition, - } - } else { - touchDataRef.current = null - } - } - - const handleTouchMove = (e: React.TouchEvent) => { - if (!touchDataRef.current || mergedProps.cursor === 'static') return - - setIsCaretDragging(true) - - const touch = e.touches[0] - const deltaX = touch.clientX - touchDataRef.current.startX - - const charWidth = charWidthRef.current - const moveChars = Math.round(deltaX / charWidth) - let newCaretPosition = touchDataRef.current.startCaretPosition + moveChars - // Boundary handling - newCaretPosition = Math.max(0, Math.min(newCaretPosition, value.length)) - setCaretPosition(newCaretPosition) - mergedProps.onCursorMove?.(newCaretPosition) - - // Prevent missing touchend event - if (touchMoveTimeoutRef.current) { - clearTimeout(touchMoveTimeoutRef.current) - } - touchMoveTimeoutRef.current = setTimeout(() => { - setIsCaretDragging(false) - touchMoveTimeoutRef.current = null - }, 500) - } - - const handleTouchEnd = () => { - touchDataRef.current = null - setIsCaretDragging(false) + setCaretPosition(isRight ? index + 1 : index) } const chars = (value + '').split('') + return withNativeProps( mergedProps,
( className={`${classPrefix}-content`} ref={contentRef} role='textbox' - tabIndex={mergedProps.disabled ? undefined : 0} aria-disabled={mergedProps.disabled} aria-label={mergedProps.placeholder} onClick={setCaretPositionToEnd} - onTouchStart={handleTouchStart} - onTouchMove={handleTouchMove} - onTouchEnd={handleTouchEnd} > {chars.slice(0, caretPosition).map((i: string, index: number) => ( - + {i} ))} - {/* Only change cursor spacing style when focused */} {hasFocus && (
-
+
)} {chars.slice(caretPosition).map((i: string, index: number) => ( @@ -327,26 +223,21 @@ export const VirtualInput = forwardRef( ))}
- {mergedProps.clearable && - !!value && - hasFocus && - !mergedProps.disabled && ( -
{ - e.stopPropagation() - setValue('') - mergedProps.onClear?.() - }} - onMouseDown={e => { - e.preventDefault() - }} - role='button' - aria-label={locale.Input.clear} - > - {clearIcon} -
- )} + {mergedProps.clearable && !!value && hasFocus && ( +
{ + e.stopPropagation() + setValue('') + setCaretPosition(0) + mergedProps.onClear?.() + }} + role='button' + aria-label={locale.Input.clear} + > + {clearIcon} +
+ )} {[undefined, null, ''].includes(value) && (
{mergedProps.placeholder}