Repository: ant-design/ant-design-mobile
Pull request #5497 (2022-07-22 06:28:11)

feat(Rate): support slide gesture
#5469

这次通过`onTouchStart`和`onTouchMove`实现= =。

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

Patch:
diff --git a/src/components/floating-bubble/floating-bubble.tsx b/src/components/floating-bubble/floating-bubble.tsx
index 88d34c26d..046988858 100644
--- a/src/components/floating-bubble/floating-bubble.tsx
+++ b/src/components/floating-bubble/floating-bubble.tsx
@@ -93,7 +93,7 @@ export const FloatingBubble: FC<FloatingBubbleProps> = p => {
       // the component won't trigger drag logic if the user just clicked on the component.
       filterTaps: true,
       // set constraints to the user gesture
-      bounds: boundaryRef as any, // Temp fix. Seems to be a bug with use-gesture.
+      bounds: boundaryRef,
       from: () => [x.get(), y.get()],
     }
   )
diff --git a/src/components/rate/rate.less b/src/components/rate/rate.less
index 85b0efb72..a7a59ced5 100644
--- a/src/components/rate/rate.less
+++ b/src/components/rate/rate.less
@@ -5,6 +5,7 @@
   --active-color: #ffd21e;
   --inactive-color: var(--adm-color-border);
   display: inline-flex;
+  touch-action: pan-y;
   &-box {
     position: relative;
   }
diff --git a/src/components/rate/rate.tsx b/src/components/rate/rate.tsx
index 345bb70e9..f044707d3 100644
--- a/src/components/rate/rate.tsx
+++ b/src/components/rate/rate.tsx
@@ -1,9 +1,10 @@
-import React, { FC } from 'react'
+import React, { FC, useRef } from 'react'
 import classNames from 'classnames'
 import { NativeProps, withNativeProps } from '../../utils/native-props'
 import { mergeProps } from '../../utils/with-default-props'
 import { usePropsValue } from '../../utils/use-props-value'
 import { Star } from './star'
+import { useDrag } from '@use-gesture/react'
 
 const classPrefix = `adm-rate`
 
@@ -30,6 +31,8 @@ const defaultProps = {
 export const Rate: FC<RateProps> = p => {
   const props = mergeProps(defaultProps, p)
   const [value, setValue] = usePropsValue(props)
+
+  const containerRef = useRef<HTMLDivElement>(null)
   const starList = Array(props.count).fill(null)
 
   function renderStar(v: number, half: boolean) {
@@ -56,12 +59,43 @@ export const Rate: FC<RateProps> = p => {
       </div>
     )
   }
+
+  const bind = useDrag(
+    state => {
+      if (props.readOnly) return
+      const {
+        xy: [clientX],
+      } = state
+
+      const container = containerRef.current
+      if (!container) return
+      const rect = container.getBoundingClientRect()
+
+      const rawValue = ((clientX - rect.left) / rect.width) * 5
+
+      const roundedValue = props.allowHalf
+        ? Math.round(rawValue * 2) / 2
+        : Math.round(rawValue)
+
+      setValue(roundedValue)
+    },
+    {
+      axis: 'x',
+      preventScroll: true,
+      pointer: {
+        touch: true,
+      },
+    }
+  )
+
   return withNativeProps(
     props,
     <div
       className={classPrefix}
       role='radiogroup'
       aria-readonly={props.readOnly}
+      ref={containerRef}
+      {...bind()}
     >
       {starList.map((_, i) => (
         <div key={i} className={classNames(`${classPrefix}-box`)}>


Test patch:
diff --git a/src/components/floating-bubble/tests/floating-bubble.test.tsx b/src/components/floating-bubble/tests/floating-bubble.test.tsx
new file mode 100644
index 000000000..140da43fc
--- /dev/null
+++ b/src/components/floating-bubble/tests/floating-bubble.test.tsx
@@ -0,0 +1,149 @@
+import React from 'react'
+import { render, fireEvent, waitFor, sleep } from 'testing'
+import FloatingBubble from '..'
+
+const classPrefix = `adm-floating-bubble`
+
+function mockRect(el: Element, value: any) {
+  jest.spyOn(el, 'getBoundingClientRect').mockReturnValue(value)
+}
+
+function mockBoundaryRect() {
+  const boundary = document.querySelectorAll(`.${classPrefix}-boundary`)[0]
+  mockRect(boundary, {
+    top: 0,
+    right: 300,
+    bottom: 600,
+    left: 0,
+    x: 0,
+    y: 0,
+  })
+}
+
+function mockButtonRect(data: any = {}) {
+  const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+  const defaultRect = {
+    x: 252,
+    left: 252,
+    right: 300,
+    y: 500,
+    top: 552,
+    bottom: 600,
+  }
+  mockRect(btn, {
+    ...defaultRect,
+    ...data,
+  })
+}
+
+describe('FloatingBubble', () => {
+  test('onClick should be called', async () => {
+    const onClick = jest.fn()
+    render(<FloatingBubble onClick={onClick} />)
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    fireEvent.click(btn)
+    waitFor(() => expect(onClick).toBeCalled())
+  })
+
+  test('automatic magnetic attraction to the left', async () => {
+    render(<FloatingBubble axis='xy' magnetic='x' />)
+    mockBoundaryRect()
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    mockButtonRect()
+
+    fireEvent.mouseDown(btn, {
+      buttons: 1,
+      clientX: 303,
+    })
+    fireEvent.mouseMove(btn, {
+      buttons: 1,
+      clientX: 100,
+    })
+    mockButtonRect({
+      x: 100,
+      left: 100,
+      right: 148,
+    })
+    fireEvent.mouseUp(btn)
+
+    await waitFor(() =>
+      expect(btn).toHaveStyle('transform: translate(-300px, 0px)')
+    )
+  })
+
+  test('automatic magnetic attraction to the right', async () => {
+    render(<FloatingBubble axis='xy' magnetic='x' />)
+    mockBoundaryRect()
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    mockButtonRect()
+
+    fireEvent.mouseDown(btn, {
+      buttons: 1,
+      clientX: 255,
+    })
+    fireEvent.mouseMove(btn, {
+      buttons: 1,
+      clientX: 200,
+    })
+    mockButtonRect({
+      x: 200,
+      left: 200,
+      right: 248,
+    })
+    fireEvent.mouseUp(btn)
+
+    await sleep(0)
+    expect(btn).toHaveStyle('transform: translate(0px, 0px)')
+  })
+
+  test('automatic magnetic attraction to the top', async () => {
+    render(<FloatingBubble axis='xy' magnetic='y' />)
+    mockBoundaryRect()
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    mockButtonRect()
+
+    fireEvent.mouseDown(btn, {
+      buttons: 1,
+      clientY: 503,
+    })
+    fireEvent.mouseMove(btn, {
+      buttons: 1,
+      clientY: 200,
+    })
+    mockButtonRect({
+      y: 200,
+      top: 200,
+      bottom: 248,
+    })
+    fireEvent.mouseUp(btn)
+
+    await waitFor(() =>
+      expect(btn).toHaveStyle('transform: translate(0px, -500px)')
+    )
+  })
+
+  test('automatic magnetic attraction to the top', async () => {
+    render(<FloatingBubble axis='xy' magnetic='y' />)
+    mockBoundaryRect()
+    const btn = document.querySelectorAll(`.${classPrefix}-button`)[0]
+    mockButtonRect()
+
+    fireEvent.mouseDown(btn, {
+      buttons: 1,
+      clientY: 603,
+    })
+    fireEvent.mouseMove(btn, {
+      buttons: 1,
+      clientY: 548,
+    })
+    mockButtonRect({
+      y: 500,
+      top: 500,
+      bottom: 548,
+    })
+    fireEvent.mouseUp(btn)
+
+    await sleep(0)
+    expect(btn).toHaveStyle('transform: translate(0px, 0px)')
+  })
+})
diff --git a/src/components/image/test/image.test.tsx b/src/components/image/test/image.test.tsx
index 0b54b834e..8c61856e7 100644
--- a/src/components/image/test/image.test.tsx
+++ b/src/components/image/test/image.test.tsx
@@ -1,15 +1,13 @@
 import React from 'react'
-import { fireEvent, render, testA11y } from 'testing'
+import { fireEvent, render, act } from 'testing'
 import Image from '../index'
 
+const classPrefix = `adm-image`
+
 const demoSrc =
   'https://images.unsplash.com/photo-1567945716310-4745a6b7844b?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60'
 
-describe('image', () => {
-  test('a11y', async () => {
-    await testA11y(<Image src={demoSrc} />)
-  })
-
+describe('Image', () => {
   test('onContainerClick can work', () => {
     const onContainerClick = jest.fn()
     const { getByTestId } = render(
@@ -22,4 +20,53 @@ describe('image', () => {
     fireEvent.click(getByTestId('img'))
     expect(onContainerClick).toBeCalledTimes(1)
   })
+
+  test('load successfully', () => {
+    render(<Image src={demoSrc} />)
+    const img = document.querySelectorAll(`.${classPrefix}-img`)[0]
+    fireEvent.load(img)
+    expect(img).toHaveAttribute('src', demoSrc)
+  })
+
+  test('load failed', () => {
+    render(<Image src='404' />)
+    const img = document.querySelectorAll(`.${classPrefix}-img`)[0]
+    fireEvent.error(img)
+    expect(img).not.toBeInTheDocument()
+    expect(
+      document.querySelectorAll(`.${classPrefix}-tip`)[0]
+    ).toBeInTheDocument()
+  })
+
+  test('lazy load should be work', () => {
+    // mock useInViewport
+    // https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useInViewport/__tests__/index.test.ts
+    const mockIntersectionObserver = jest.fn().mockReturnValue({
+      observe: () => null,
+      disconnect: () => null,
+    })
+    window.IntersectionObserver = mockIntersectionObserver
+
+    render(<Image src={demoSrc} lazy />)
+    const img = document.querySelectorAll(`.${classPrefix}-img`)[0]
+    expect(img).not.toHaveAttribute('src')
+
+    const calls = mockIntersectionObserver.mock.calls
+    const [onChange] = calls[calls.length - 1]
+    act(() => {
+      onChange([
+        {
+          isIntersecting: true,
+        },
+      ])
+    })
+    expect(img).toHaveAttribute('src', demoSrc)
+  })
+
+  test('renders with width and height', () => {
+    const { getByTestId } = render(
+      <Image src={demoSrc} width={100} height={100} data-testid='image' />
+    )
+    expect(getByTestId('image')).toHaveStyle('--width: 100px;--height: 100px')
+  })
 })