Skip to content

Commit 489d444

Browse files
authored
feat: 解决 VirtualInput 无障碍读屏时会读出键盘内容的问题 (#6954)
* feat: 解决 VirtualInput 无障碍读屏时会读出键盘内容的问题 * feat: 弃用 ahooks 自行实现捕获阶段的 clickOutside * test: 修正单测 * fix: 解决 iOS 旁白模式下聚焦到输入框光标自动定位到最前面的问题 * fix: 重新暴露 onClick props、修正单测 * fix: type * fix: 陷阱元素不能禁用指针事件,否则无法命中陷阱 * fix: span * fix: lint * feat: 重新整理 NumberKeyboard 事件处理,去掉 preventDefault,从而让 virtual-input-content 的 blur 事件可以触发 * feat: 注释 * fix: cr * feat: 考虑系统弹窗等使得 touchend 不触发的情况,使用 touchcancel 兜底 * fix: remove console.log * fix: 修正输入内容超长时,无法自动滚到最右侧的问题 * test: 补充 scrollToEnd 的单测
1 parent a7defc3 commit 489d444

File tree

5 files changed

+143
-70
lines changed

5 files changed

+143
-70
lines changed

src/components/number-keyboard/number-keyboard.tsx

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,6 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
103103
e: TouchEvent<HTMLDivElement> | MouseEvent<HTMLDivElement>,
104104
key: string
105105
) => {
106-
e.preventDefault()
107-
108106
switch (key) {
109107
case 'BACKSPACE':
110108
onDelete?.()
@@ -152,6 +150,17 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
152150
)
153151
}
154152

153+
const onBackspaceTouchStart = () => {
154+
stopContinueClear()
155+
startContinueClear()
156+
}
157+
158+
const onBackspaceTouchEnd = (e: TouchEvent<HTMLDivElement>) => {
159+
e.preventDefault() // 短按时,touchend 会阻止后续 click 事件触发,防止删除两次
160+
stopContinueClear()
161+
onKeyPress(e, 'BACKSPACE')
162+
}
163+
155164
const renderKey = (key: string, index: number) => {
156165
const isNumberKey = /^\d$/.test(key)
157166
const isBackspace = key === 'BACKSPACE'
@@ -175,25 +184,13 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
175184
key={key}
176185
className={className}
177186
// 仅为 backspace 绑定,支持长按快速删除
178-
onTouchStart={
179-
isBackspace
180-
? () => {
181-
stopContinueClear()
182-
startContinueClear()
183-
}
184-
: undefined
185-
}
186-
onTouchEnd={
187-
isBackspace
188-
? e => {
189-
stopContinueClear()
190-
onKeyPress(e, key)
191-
}
192-
: undefined
193-
}
187+
onTouchStart={isBackspace ? onBackspaceTouchStart : undefined}
188+
onTouchEnd={isBackspace ? onBackspaceTouchEnd : undefined}
189+
onTouchCancel={isBackspace ? stopContinueClear : undefined}
194190
// <div role="button" title="1" onTouchEnd={e => {}}>1</div> 安卓上 talback 可读不可点
195191
// see https://ua-gilded-eef7f9.netlify.app/grid-button-bug.html
196-
// 所以还是绑定 click,通过 touchEnd 的 preventDefault 防重复触发
192+
// 所以普通按钮绑定 click 事件,而 backspace 仍然额外绑定 touch 事件支持长按删除
193+
// backspace touchend 时会 preventDefault 阻止其后续 click 事件
197194
onClick={(e: MouseEvent<HTMLDivElement>) => {
198195
stopContinueClear()
199196
onKeyPress(e, key)
@@ -219,13 +216,7 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
219216
>
220217
{withNativeProps(
221218
props,
222-
<div
223-
ref={keyboardRef}
224-
className={classPrefix}
225-
onMouseDown={e => {
226-
e.preventDefault()
227-
}}
228-
>
219+
<div ref={keyboardRef} className={classPrefix}>
229220
{renderHeader()}
230221
<div className={`${classPrefix}-wrapper`}>
231222
<div
@@ -237,15 +228,12 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
237228
</div>
238229
{!!confirmText && (
239230
<div className={`${classPrefix}-confirm`}>
231+
{/* 与上面的 backspace 代码是一样的 */}
240232
<div
241233
className={`${classPrefix}-key ${classPrefix}-key-extra ${classPrefix}-key-bs`}
242-
onTouchStart={() => {
243-
startContinueClear()
244-
}}
245-
onTouchEnd={e => {
246-
stopContinueClear()
247-
onKeyPress(e, 'BACKSPACE')
248-
}}
234+
onTouchStart={onBackspaceTouchStart}
235+
onTouchEnd={onBackspaceTouchEnd}
236+
onTouchCancel={stopContinueClear}
249237
onClick={e => {
250238
stopContinueClear()
251239
onKeyPress(e, 'BACKSPACE')

src/components/virtual-input/tests/virtual-input.test.tsx

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function getSiblingElements(element: Element | null) {
1717
current = current?.nextElementSibling
1818
}
1919
current = element?.previousElementSibling
20-
while (current) {
20+
while (current && current.className !== `${classPrefix}-trap`) {
2121
prevElements.push(current)
2222
current = current?.previousElementSibling
2323
}
@@ -117,9 +117,9 @@ describe('VirtualInput', () => {
117117

118118
test('focus and blur', async () => {
119119
render(<VirtualInput data-testid='virtualInput' clearable value='abc' />)
120-
fireEvent.focus(screen.getByTestId('virtualInput'))
120+
fireEvent.focus(document.querySelector(`.${classPrefix}-content`)!)
121121
expect(document.querySelector(`.${classPrefix}-caret`)).toBeInTheDocument()
122-
fireEvent.blur(screen.getByTestId('virtualInput'))
122+
fireEvent.click(document.body) // 点击空白处
123123
expect(
124124
document.querySelector(`.${classPrefix}-caret`)
125125
).not.toBeInTheDocument()
@@ -161,7 +161,7 @@ describe('VirtualInput', () => {
161161
expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent(
162162
'Value'
163163
)
164-
fireEvent.focus(screen.getByTestId('virtualInput'))
164+
fireEvent.focus(document.querySelector(`.${classPrefix}-content`)!)
165165
expect(document.querySelector(`.${classPrefix}-clear`)).toBeInTheDocument()
166166
fireEvent.click(document.querySelector(`.${classPrefix}-clear`) as any)
167167
expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent(
@@ -178,7 +178,7 @@ describe('VirtualInput', () => {
178178
keyboard={<NumberKeyboard title='title' />}
179179
/>
180180
)
181-
fireEvent.focus(screen.getByTestId('virtualInput'))
181+
fireEvent.focus(document.querySelector(`.${classPrefix}-content`)!)
182182

183183
await waitFor(() => {
184184
expect(
@@ -232,7 +232,7 @@ describe('VirtualInput', () => {
232232
}
233233
render(<Wrapper />)
234234
const input = screen.getByTestId('virtualInput')
235-
fireEvent.focus(input)
235+
fireEvent.focus(document.querySelector(`.${classPrefix}-content`)!)
236236

237237
await waitFor(() => {
238238
expect(
@@ -299,7 +299,7 @@ describe('VirtualInput', () => {
299299
}
300300
render(<Wrapper />)
301301
const input = screen.getByTestId('virtualInput')
302-
fireEvent.focus(input)
302+
fireEvent.focus(document.querySelector(`.${classPrefix}-content`)!)
303303

304304
await waitFor(() => {
305305
expect(
@@ -390,7 +390,7 @@ describe('VirtualInput', () => {
390390
}
391391
render(<Wrapper />)
392392
const input = screen.getByTestId('virtualInput')
393-
fireEvent.focus(input)
393+
fireEvent.focus(document.querySelector(`.${classPrefix}-content`)!)
394394

395395
await waitFor(() => {
396396
expect(
@@ -523,7 +523,7 @@ describe('VirtualInput', () => {
523523
}
524524
render(<Wrapper />)
525525
const input = screen.getByTestId('virtualInput')
526-
fireEvent.focus(input)
526+
fireEvent.focus(document.querySelector(`.${classPrefix}-content`)!)
527527

528528
await waitFor(() => {
529529
expect(
@@ -611,6 +611,55 @@ describe('VirtualInput', () => {
611611
}
612612
})
613613

614+
test('scrollToEnd function', async () => {
615+
const Wrapper = () => {
616+
const [value, setValue] = React.useState('')
617+
return (
618+
<VirtualInput
619+
data-testid='virtualInput'
620+
value={value}
621+
onChange={setValue}
622+
keyboard={<NumberKeyboard />}
623+
/>
624+
)
625+
}
626+
render(<Wrapper />)
627+
const input = screen.getByTestId('virtualInput')
628+
const content = input.querySelector(
629+
`.${classPrefix}-content`
630+
) as HTMLElement
631+
632+
// Mock scroll properties
633+
content.scrollLeft = 0
634+
Object.defineProperty(content, 'clientWidth', {
635+
get: function () {
636+
return (content.textContent || '').length * 20
637+
},
638+
})
639+
640+
// Test focus scenario
641+
fireEvent.focus(content)
642+
expect(content.scrollLeft).toBe(0)
643+
644+
// Test input scenario
645+
content.scrollLeft = 0
646+
fireEvent.click(screen.getByText('1')) // Simulate keyboard input
647+
await waitFor(() => {
648+
expect(content.scrollLeft).toBe(20) // Should scroll on input
649+
})
650+
651+
// Test with long content
652+
content.scrollLeft = 0
653+
fireEvent.click(screen.getByText('2'))
654+
fireEvent.click(screen.getByText('3'))
655+
fireEvent.click(screen.getByText('4'))
656+
fireEvent.click(screen.getByText('5'))
657+
658+
await waitFor(() => {
659+
expect(content.scrollLeft).toBe(100) // Should stay scrolled to end
660+
})
661+
})
662+
614663
test('disable caret position', async () => {
615664
const KeyBoardClassPrefix = 'adm-number-keyboard'
616665
const Wrapper = () => {
@@ -625,7 +674,7 @@ describe('VirtualInput', () => {
625674
}
626675
render(<Wrapper />)
627676
const input = screen.getByTestId('virtualInput')
628-
fireEvent.focus(input)
677+
fireEvent.focus(document.querySelector(`.${classPrefix}-content`)!)
629678

630679
await waitFor(() => {
631680
expect(
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useEffect } from 'react'
2+
3+
// 监听点击组件外部的事件
4+
function useClickOutside(
5+
handler: (event: MouseEvent) => void,
6+
ref: React.RefObject<HTMLElement>
7+
) {
8+
useEffect(() => {
9+
function handleClick(event: MouseEvent) {
10+
if (!ref.current || ref.current.contains(event.target as Node)) {
11+
return
12+
}
13+
handler(event)
14+
}
15+
16+
// 在捕获阶段监听,以确保在事件被阻止传播之前触发
17+
document.addEventListener('click', handleClick, true)
18+
19+
return () => {
20+
document.removeEventListener('click', handleClick, true)
21+
}
22+
}, [handler, ref])
23+
}
24+
25+
export default useClickOutside

src/components/virtual-input/virtual-input.less

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@
4141
scrollbar-width: none;
4242
}
4343

44+
// note: 在 iOS 旁白模式下,role="textbox" 聚焦时会自动给第一个可见子元素发送 click 事件
45+
// 因此在这里放一个陷阱元素,将事件引导到至此,避免光标移动
46+
// 实测至少 10x10 大小才有效
47+
&-trap {
48+
width: 10px;
49+
height: 10px;
50+
position: absolute;
51+
opacity: 0;
52+
}
53+
4454
&-placeholder {
4555
display: block;
4656
position: absolute;
@@ -72,7 +82,7 @@
7282
left: 0;
7383
z-index: 1;
7484
}
75-
&:focus {
85+
&-focused {
7686
outline: none;
7787
.@{class-prefix-virtual-input}-caret {
7888
animation-name: adm-caret-blink;

0 commit comments

Comments
 (0)