Repository: ant-design/ant-design-mobile
Pull request #6494 (2023-12-20 14:25:02)

feat: 优化数字键盘及虚拟输入框无障碍
fix: #6486
1：禁用无障碍模式下，mask背景读取焦点  
2：修复虚拟键盘role为textbox  
3：修复数字键盘收起及删除为中文，更改按钮及数字role类型为button  

github-actions[bot]: PR preview has been successfully built and deployed to https://antd-mobile-preview-pr-6494.surge.sh

Patch:
diff --git a/jest.config.js b/jest.config.js
index 9cf30a608..9cf1772d7 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,7 +1,7 @@
 module.exports = {
   preset: 'ts-jest',
   testEnvironment: 'jsdom',
-  // maxConcurrency: 1,
+  maxConcurrency: 1,
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
   modulePathIgnorePatterns: ['<rootDir>/lib/', '<rootDir>/dist/'],
   moduleDirectories: ['node_modules', 'src/tests'],
diff --git a/src/components/mask/mask.tsx b/src/components/mask/mask.tsx
index 9cc8a248a..99d0aadaf 100644
--- a/src/components/mask/mask.tsx
+++ b/src/components/mask/mask.tsx
@@ -101,6 +101,7 @@ export const Mask: FC<MaskProps> = p => {
       <animated.div
         className={classPrefix}
         ref={ref}
+        aria-hidden
         style={{
           ...props.style,
           background,
diff --git a/src/components/number-keyboard/number-keyboard.tsx b/src/components/number-keyboard/number-keyboard.tsx
index 2907c8423..ab4ecac48 100644
--- a/src/components/number-keyboard/number-keyboard.tsx
+++ b/src/components/number-keyboard/number-keyboard.tsx
@@ -8,6 +8,7 @@ import Popup, { PopupProps } from '../popup'
 import { NativeProps, withNativeProps } from '../../utils/native-props'
 import SafeArea from '../safe-area'
 import { useMemoizedFn } from 'ahooks'
+import { useConfig } from '../config-provider'
 
 const classPrefix = 'adm-number-keyboard'
 
@@ -59,6 +60,8 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
     onInput,
   } = props
 
+  const { locale } = useConfig()
+
   const keyboardRef = useRef<HTMLDivElement | null>(null)
 
   const keys = useMemo(() => {
@@ -138,8 +141,8 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
             onClick={() => {
               props.onClose?.()
             }}
-            role='grid'
-            title='CLOSE'
+            role='button'
+            title={locale.common.close}
             tabIndex={-1}
           >
             <DownOutline />
@@ -160,7 +163,7 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
 
     const ariaProps = key
       ? {
-          role: 'grid',
+          role: 'button',
           title: key,
           tabIndex: -1,
         }
@@ -233,8 +236,8 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
                     // Long press should not trigger native context menu
                     e.preventDefault()
                   }}
-                  title='BACKSPACE'
-                  role='grid'
+                  title={locale.Input.clear}
+                  role='button'
                   tabIndex={-1}
                 >
                   <TextDeletionOutline />
@@ -242,7 +245,7 @@ export const NumberKeyboard: FC<NumberKeyboardProps> = p => {
                 <div
                   className={`${classPrefix}-key ${classPrefix}-key-extra ${classPrefix}-key-ok`}
                   onTouchEnd={e => onKeyPress(e, 'OK')}
-                  role='grid'
+                  role='button'
                   tabIndex={-1}
                   aria-label={confirmText}
                 >
diff --git a/src/components/tabs/tabs.tsx b/src/components/tabs/tabs.tsx
index 751ed4473..2bd099e13 100644
--- a/src/components/tabs/tabs.tsx
+++ b/src/components/tabs/tabs.tsx
@@ -35,6 +35,12 @@ export type TabsProps = {
   onChange?: (key: string) => void
   children?: ReactNode
   direction?: 'ltr' | 'rtl'
+  /**
+   * @experimental Support disabled auto scroll when Tabs header content change.
+   * This API name or function may change in the future.
+   * Please lock the version if you want to use it.
+   */
+  autoScroll?: boolean
 } & NativeProps<
   | '--fixed-active-line-width'
   | '--active-line-height'
@@ -83,7 +89,7 @@ export const Tabs: FC<TabsProps> = p => {
     },
   })
 
-  const [{ x, width }, api] = useSpring(() => ({
+  const [{ x, width }, inkApi] = useSpring(() => ({
     x: 0,
     width: 0,
     config: {
@@ -108,12 +114,13 @@ export const Tabs: FC<TabsProps> = p => {
     },
   }))
 
-  function animate(immediate = false) {
+  function animate(immediate = false, fromMutation = false) {
     const container = tabListContainerRef.current
     if (!container) return
+
     const activeIndex = keyToIndexRecord[activeKey as string]
     if (activeIndex === undefined) {
-      api.start({
+      inkApi.start({
         x: 0,
         width: 0,
         immediate: true,
@@ -161,7 +168,7 @@ export const Tabs: FC<TabsProps> = p => {
       x = -(containerWidth - x - w)
     }
 
-    api.start({
+    inkApi.start({
       x,
       width,
       immediate,
@@ -193,11 +200,13 @@ export const Tabs: FC<TabsProps> = p => {
       )
     }
 
-    scrollApi.start({
-      scrollLeft: nextScrollLeft,
-      from: { scrollLeft: containerScrollLeft },
-      immediate,
-    })
+    if (!fromMutation || props.autoScroll !== false) {
+      scrollApi.start({
+        scrollLeft: nextScrollLeft,
+        from: { scrollLeft: containerScrollLeft },
+        immediate,
+      })
+    }
   }
 
   useIsomorphicLayoutEffect(() => {
@@ -214,7 +223,7 @@ export const Tabs: FC<TabsProps> = p => {
 
   useMutationEffect(
     () => {
-      animate(!x.isAnimating)
+      animate(!x.isAnimating, true)
     },
     tabListContainerRef,
     {
diff --git a/src/components/virtual-input/virtual-input.tsx b/src/components/virtual-input/virtual-input.tsx
index 26ba5b620..955496f4b 100644
--- a/src/components/virtual-input/virtual-input.tsx
+++ b/src/components/virtual-input/virtual-input.tsx
@@ -130,7 +130,7 @@ export const VirtualInput = forwardRef<VirtualInputRef, VirtualInputProps>(
           [`${classPrefix}-disabled`]: props.disabled,
         })}
         tabIndex={props.disabled ? undefined : 0}
-        role='option'
+        role='textbox'
         onFocus={onFocus}
         onBlur={onBlur}
         onClick={props.onClick}


Test patch:
diff --git a/src/components/action-sheet/tests/__snapshots__/action-sheet.test.tsx.snap b/src/components/action-sheet/tests/__snapshots__/action-sheet.test.tsx.snap
index 0f64c3b4b..14dcaea0e 100644
--- a/src/components/action-sheet/tests/__snapshots__/action-sheet.test.tsx.snap
+++ b/src/components/action-sheet/tests/__snapshots__/action-sheet.test.tsx.snap
@@ -18,6 +18,7 @@ exports[`ActionSheet basic usage 1`] = `
     class="adm-popup adm-action-sheet-popup"
   >
     <div
+      aria-hidden="true"
       class="adm-mask"
       style="background: rgba(0, 0, 0, 0.55); opacity: 1;"
     >
diff --git a/src/components/dialog/tests/dialog.test.tsx b/src/components/dialog/tests/dialog.test.tsx
index 4df06fe0f..5536f7717 100644
--- a/src/components/dialog/tests/dialog.test.tsx
+++ b/src/components/dialog/tests/dialog.test.tsx
@@ -1,3 +1,5 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+
 import React from 'react'
 import {
   render,
@@ -6,6 +8,7 @@ import {
   waitFor,
   waitForElementToBeRemoved,
   screen,
+  waitFakeTimers,
 } from 'testing'
 import Dialog, { DialogAlertProps } from '..'
 import { act } from '@testing-library/react'
@@ -65,8 +68,11 @@ describe('Dialog', () => {
     )
 
     fireEvent.click(screen.getByRole('button', { name: 'btn' }))
-    const mask = await screen.findByRole('button', { name: '背景蒙层' })
+    await waitFakeTimers()
+
+    const mask = document.querySelector('.adm-mask-aria-button')!
     fireEvent.click(mask)
+
     await waitForElementToBeRemoved(mask)
     expect(onClose).toBeCalled()
     expect(afterClose).toBeCalled()
diff --git a/src/components/image-viewer/tests/__snapshots__/image-viewer.test.tsx.snap b/src/components/image-viewer/tests/__snapshots__/image-viewer.test.tsx.snap
index 20c54a4c1..8713f9850 100644
--- a/src/components/image-viewer/tests/__snapshots__/image-viewer.test.tsx.snap
+++ b/src/components/image-viewer/tests/__snapshots__/image-viewer.test.tsx.snap
@@ -24,6 +24,7 @@ exports[`ImageViewer.Multi calling ref.current.swipeTo before initialization 2`]
     </span>
   </button>
   <div
+    aria-hidden="true"
     class="adm-mask"
     style="background: rgba(0, 0, 0, 0.75); opacity: 1;"
   >
diff --git a/src/components/image-viewer/tests/image-viewer.test.tsx b/src/components/image-viewer/tests/image-viewer.test.tsx
index 9e85d70f9..3645a2322 100644
--- a/src/components/image-viewer/tests/image-viewer.test.tsx
+++ b/src/components/image-viewer/tests/image-viewer.test.tsx
@@ -87,6 +87,14 @@ jest.mock('../../../utils/use-drag-and-pinch', () => {
   }
 })
 
+async function getImages() {
+  const images = await screen.findAllByText(
+    (content, element) => element?.tagName.toLowerCase() === 'img'
+  )
+
+  return images[0]
+}
+
 describe('ImageViewer.Multi', () => {
   test('calling ref.current.swipeTo before initialization', async () => {
     function App() {
@@ -149,12 +157,13 @@ describe('ImageViewer.Multi', () => {
       </>
     )
     fireEvent.click(screen.getByText('show'))
-    const imgs = await screen.findAllByRole('img')
-    expect(imgs[0]).toBeVisible()
+    const img = await getImages()
+
+    expect(img).toBeVisible()
     await act(async () => {
-      await userEvent.click(imgs[0])
+      await userEvent.click(img)
     })
-    await waitFor(() => expect(imgs[0]).not.toBeVisible())
+    await waitFor(() => expect(img).not.toBeVisible())
   })
 
   test('slide and slide with pinched should be work', async () => {
@@ -174,7 +183,7 @@ describe('ImageViewer.Multi', () => {
       )
     })
 
-    await screen.findAllByRole('img')
+    await getImages()
 
     G?.onPinch({
       origin: [235, 202],
@@ -254,7 +263,8 @@ describe('ImageViewer', () => {
       </button>
     )
     fireEvent.click(screen.getByText('show'))
-    const img = await screen.findByRole('img')
+
+    const img = await getImages()
     expect(img).toBeVisible()
 
     act(() => {
diff --git a/src/components/modal/tests/modal.test.tsx b/src/components/modal/tests/modal.test.tsx
index 0ca1acfe2..e212458ec 100644
--- a/src/components/modal/tests/modal.test.tsx
+++ b/src/components/modal/tests/modal.test.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
 import React from 'react'
 import {
   render,
@@ -7,6 +8,7 @@ import {
   waitForElementToBeRemoved,
   screen,
   act,
+  waitFakeTimers,
 } from 'testing'
 import Modal, { ModalAlertProps } from '..'
 
@@ -66,9 +68,13 @@ describe('Modal', () => {
     )
 
     fireEvent.click(screen.getByRole('button', { name: 'btn' }))
-    const mask = await screen.findByRole('button', { name: '背景蒙层' })
+    await waitFakeTimers()
+
+    const mask = document.querySelector('.adm-mask-aria-button')!
     fireEvent.click(mask)
+
     await waitForElementToBeRemoved(mask)
+
     expect(onClose).toBeCalled()
     expect(afterClose).toBeCalled()
   })
diff --git a/src/components/number-keyboard/tests/number-keyboard.test.tsx b/src/components/number-keyboard/tests/number-keyboard.test.tsx
index ca7e8c73c..d14db2c02 100644
--- a/src/components/number-keyboard/tests/number-keyboard.test.tsx
+++ b/src/components/number-keyboard/tests/number-keyboard.test.tsx
@@ -27,7 +27,8 @@ describe('NumberKeyboard', () => {
   test('onClose should be called by close button', () => {
     const onClose = jest.fn()
     render(<NumberKeyboard visible onClose={onClose} />)
-    fireEvent.click(screen.getByTitle('CLOSE'))
+
+    fireEvent.click(screen.getByTitle('关闭'))
     expect(onClose).toBeCalledTimes(1)
   })
 
@@ -75,7 +76,7 @@ describe('NumberKeyboard', () => {
       />
     )
     const confirm = screen.getByText('confirm')
-    const del = screen.getByTitle('BACKSPACE')
+    const del = screen.getByTitle('清除')
     expect(confirm).toBeInTheDocument()
     expect(confirm).toHaveClass(
       `${classPrefix}-key-extra ${classPrefix}-key-ok`
@@ -118,7 +119,7 @@ describe('NumberKeyboard', () => {
     const popup = document.querySelector(`.${classPrefix}-popup`)
     const main = document.querySelector(`.${classPrefix}-main`)
     expect(main).not.toHaveTextContent('1234567890')
-    fireEvent.click(screen.getByTitle('CLOSE'))
+    fireEvent.click(screen.getByTitle('关闭'))
     await waitFor(() => expect(popup).not.toBeVisible())
   })
 
diff --git a/src/components/virtual-input/tests/virtual-input.test.tsx b/src/components/virtual-input/tests/virtual-input.test.tsx
index 0cd11daab..ef456a11e 100644
--- a/src/components/virtual-input/tests/virtual-input.test.tsx
+++ b/src/components/virtual-input/tests/virtual-input.test.tsx
@@ -99,8 +99,8 @@ describe('VirtualInput', () => {
     expect(document.querySelector(`.${classPrefix}-content`)).toHaveTextContent(
       '0'
     )
-    expect(screen.getByTitle('CLOSE')).toBeInTheDocument()
-    fireEvent.click(screen.getByTitle('CLOSE'))
+    expect(screen.getByTitle('关闭')).toBeInTheDocument()
+    fireEvent.click(screen.getByTitle('关闭'))
     fireEvent.blur(screen.getByTestId('virtualInput'))
     await waitFor(() => {
       expect(
diff --git a/src/tests/testing.tsx b/src/tests/testing.tsx
index 06c5916e7..b790d5d6e 100644
--- a/src/tests/testing.tsx
+++ b/src/tests/testing.tsx
@@ -116,6 +116,15 @@ export const actSleep = (time: number) => {
   return act(() => sleep(time))
 }
 
+export const waitFakeTimers = async () => {
+  for (let i = 0; i < 10; i += 1) {
+    await act(async () => {
+      jest.advanceTimersByTime(1000)
+      await Promise.resolve()
+    })
+  }
+}
+
 export const mockDrag = async (el: Element, options: any[], time?: number) => {
   const [downOptions, ...moveOptions] = options
   fireEvent.mouseDown(el, {