From a1f5a8cc1dca80f31b64b4298a14133d808a9cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 28 Apr 2022 16:13:10 +0800 Subject: [PATCH] fix: transform logic (#38) --- assets/index.less | 1 + docs/examples/controlled.tsx | 4 +- package.json | 8 +- src/MotionThumb.tsx | 142 +++++++++++++++++ src/index.tsx | 151 ++++-------------- tests/__snapshots__/index.spec.tsx.snap | 2 +- tests/index.spec.tsx | 194 ++++++++++++++---------- tests/setup.ts | 4 +- 8 files changed, 296 insertions(+), 210 deletions(-) create mode 100644 src/MotionThumb.tsx diff --git a/assets/index.less b/assets/index.less index 9a295ff..fca32a1 100644 --- a/assets/index.less +++ b/assets/index.less @@ -82,6 +82,7 @@ } // transition effect when `enter-active` + &-thumb-motion-appear-active, &-thumb-motion-enter-active { transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); diff --git a/docs/examples/controlled.tsx b/docs/examples/controlled.tsx index 9a32161..2546819 100644 --- a/docs/examples/controlled.tsx +++ b/docs/examples/controlled.tsx @@ -13,7 +13,7 @@ export default class Demo extends React.Component< render() { return ( - <> + - + ); } } diff --git a/package.json b/package.json index 50f0a49..15c1296 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ "lint": "eslint src/ --ext .ts,.tsx,.jsx,.js,.md", "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "pretty-quick": "pretty-quick", - "test": "father test", - "coverage": "father test --coverage" + "test": "umi-test", + "coverage": "umi-test --coverage" }, "dependencies": { "@babel/runtime": "^7.11.1", @@ -57,9 +57,11 @@ "@types/react": "^17.0.13", "@types/react-dom": "^16.9.0", "@umijs/fabric": "^2.0.8", + "@umijs/test": "^3.5.23", "coveralls": "^3.0.6", "cross-env": "^7.0.2", - "dumi": "^1.1.0", + "cssstyle": "^2.3.0", + "dumi": "^1.1.41-rc.0", "eslint": "^7.0.0", "father": "^2.13.4", "father-build": "^1.18.6", diff --git a/src/MotionThumb.tsx b/src/MotionThumb.tsx new file mode 100644 index 0000000..9527e24 --- /dev/null +++ b/src/MotionThumb.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; +import CSSMotion from 'rc-motion'; +import classNames from 'classnames'; +import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; +import { composeRef } from 'rc-util/lib/ref'; +import type { SegmentedValue } from '.'; + +type ThumbReact = { + left: number; + width: number; +} | null; + +export interface MotionThumbInterface { + containerRef: React.RefObject; + value: SegmentedValue; + getValueIndex: (value: SegmentedValue) => number; + prefixCls: string; + motionName: string; + onMotionStart: VoidFunction; + onMotionEnd: VoidFunction; +} + +const calcThumbStyle = ( + targetElement: HTMLElement | null | undefined, +): ThumbReact => + targetElement + ? { + left: targetElement.offsetLeft, + width: targetElement.clientWidth, + } + : null; + +const toPX = (value: number) => + value !== undefined ? `${value}px` : undefined; + +export default function MotionThumb(props: MotionThumbInterface) { + const { + prefixCls, + containerRef, + value, + getValueIndex, + motionName, + onMotionStart, + onMotionEnd, + } = props; + + const thumbRef = React.useRef(null); + const [prevValue, setPrevValue] = React.useState(value); + + // =========================== Effect =========================== + const findValueElement = (val: SegmentedValue) => { + const index = getValueIndex(val); + + const ele = containerRef.current?.querySelectorAll( + `.${prefixCls}-item`, + )[index]; + + return ele; + }; + + const [prevStyle, setPrevStyle] = React.useState(null); + const [nextStyle, setNextStyle] = React.useState(null); + + useLayoutEffect(() => { + if (prevValue !== value) { + const prev = findValueElement(prevValue); + const next = findValueElement(value); + + const calcPrevStyle = calcThumbStyle(prev); + const calcNextStyle = calcThumbStyle(next); + + setPrevValue(value); + setPrevStyle(calcPrevStyle); + setNextStyle(calcNextStyle); + + if (prev && next) { + onMotionStart(); + } else { + onMotionEnd(); + } + } + }, [value]); + + // =========================== Motion =========================== + const onAppearStart = () => { + return { + transform: `translateX(var(--thumb-start-left))`, + width: `var(--thumb-start-width)`, + }; + }; + const onAppearActive = () => { + return { + transform: `translateX(var(--thumb-active-left))`, + width: `var(--thumb-active-width)`, + }; + }; + const onAppearEnd = () => { + setPrevStyle(null); + setNextStyle(null); + onMotionEnd(); + }; + + // =========================== Render =========================== + // No need motion when nothing exist in queue + if (!prevStyle || !nextStyle) { + return null; + } + + return ( + + {({ className: motionClassName, style: motionStyle }, ref) => { + const mergedStyle = { + ...motionStyle, + '--thumb-start-left': toPX(prevStyle?.left), + '--thumb-start-width': toPX(prevStyle?.width), + '--thumb-active-left': toPX(nextStyle?.left), + '--thumb-active-width': toPX(nextStyle?.width), + } as React.CSSProperties; + + // It's little ugly which should be refactor when @umi/test update to latest jsdom + const motionProps = { + ref: composeRef(thumbRef, ref), + style: mergedStyle, + className: classNames(`${prefixCls}-thumb`, motionClassName), + }; + + if (process.env.NODE_ENV === 'test') { + (motionProps as any)['data-test-style'] = JSON.stringify(mergedStyle); + } + + return
; + }} + + ); +} diff --git a/src/index.tsx b/src/index.tsx index ca3531e..240ee14 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import classNames from 'classnames'; -import CSSMotion from 'rc-motion'; +// import CSSMotion from 'rc-motion'; import useMergedState from 'rc-util/lib/hooks/useMergedState'; import { composeRef } from 'rc-util/lib/ref'; import omit from 'rc-util/lib/omit'; +import MotionThumb from './MotionThumb'; export type SegmentedValue = string | number; @@ -64,11 +65,6 @@ function normalizeOptions(options: SegmentedOptions): SegmentedLabeledOption[] { }); } -const calcThumbStyle = (targetElement: HTMLElement): React.CSSProperties => ({ - transform: `translateX(${targetElement.offsetLeft}px)`, - width: targetElement.clientWidth, -}); - const InternalSegmentedOption: React.FC<{ prefixCls: string; className?: string; @@ -119,11 +115,6 @@ const InternalSegmentedOption: React.FC<{ ); }; -interface ThumbMoveStatus { - from: React.CSSProperties | null; - to: React.CSSProperties | null; -} - const Segmented = React.forwardRef( (props, ref) => { const { @@ -140,83 +131,25 @@ const Segmented = React.forwardRef( } = props; const containerRef = React.useRef(null); - const mergedRef = composeRef(containerRef, ref); - - const thumbMoveStatus = React.useRef({ - from: null, - to: null, - }); + const mergedRef = React.useMemo( + () => composeRef(containerRef, ref), + [containerRef, ref], + ); const segmentedOptions = React.useMemo(() => { return normalizeOptions(options); }, [options]); - const [selected, setSelected] = useMergedState(segmentedOptions[0]?.value, { - value: props.value, + // Note: We should not auto switch value when value not exist in options + // which may break single source of truth. + const [rawValue, setRawValue] = useMergedState(segmentedOptions[0]?.value, { + value, defaultValue, }); - const [visualSelected, setVisualSelected] = React.useState< - SegmentedRawOption | undefined - >(selected); - + // ======================= Change ======================== const [thumbShow, setThumbShow] = React.useState(false); - const doThumbAnimation = React.useCallback( - (selectedValue: SegmentedRawOption) => { - const segmentedItemIndex = segmentedOptions.findIndex( - (n) => n.value === selectedValue, - ); - - if (segmentedItemIndex < 0) { - return; - } - - // find target element - const toElement = containerRef.current?.querySelector( - `.${prefixCls}-item:nth-child(${segmentedItemIndex + 1})`, - ); - - if (toElement) { - // find source element - const fromElement = containerRef.current?.querySelector( - `.${prefixCls}-item-selected`, - ); - - if (fromElement && toElement && thumbMoveStatus.current) { - // calculate for thumb moving animation - thumbMoveStatus.current.from = calcThumbStyle( - fromElement as HTMLElement, - ); - thumbMoveStatus.current.to = calcThumbStyle( - toElement as HTMLElement, - ); - - // trigger css-motion starts - setThumbShow(true); - } - } - }, - [prefixCls, segmentedOptions], - ); - - // get latest version of `visualSelected` - const latestVisualSelected = React.useRef(visualSelected); - React.useEffect(() => { - latestVisualSelected.current = visualSelected; - }); - - React.useEffect(() => { - // Syncing `visualSelected` when `selected` changed - // and do thumb animation - if ( - (typeof selected === 'string' || typeof selected === 'number') && - selected !== latestVisualSelected.current - ) { - doThumbAnimation(selected); - } - }, [selected]); - const handleChange = ( event: React.ChangeEvent, val: SegmentedRawOption, @@ -225,39 +158,11 @@ const Segmented = React.forwardRef( return; } - setSelected(val); + setRawValue(val); onChange?.(val); }; - // --- motion event handlers for thumb move - const handleThumbEnterStart = () => { - const fromStyle = thumbMoveStatus.current.from; - if (fromStyle) { - setVisualSelected(undefined); - return fromStyle; - } - }; - - const handleThumbEnterActive = () => { - const toStyle = thumbMoveStatus.current.to; - if (toStyle) { - return toStyle; - } - }; - - const handleThumbEnterEnd = () => { - setThumbShow(false); - setVisualSelected(selected); - - if (thumbMoveStatus.current) { - thumbMoveStatus.current = { - from: null, - to: null, - }; - } - }; - const divProps = omit(restProps, ['children']); return ( @@ -273,23 +178,21 @@ const Segmented = React.forwardRef( )} ref={mergedRef} > - - {({ className: motionClassName, style: motionStyle }) => { - return ( -
- ); + getValueIndex={(val) => + segmentedOptions.findIndex((n) => n.value === val) + } + onMotionStart={() => { + setThumbShow(true); + }} + onMotionEnd={() => { + setThumbShow(false); }} - + /> {segmentedOptions.map((segmentedOption) => ( ( `${prefixCls}-item`, { [`${prefixCls}-item-selected`]: - segmentedOption.value === visualSelected, + segmentedOption.value === rawValue && !thumbShow, }, )} - checked={segmentedOption.value === selected} + checked={segmentedOption.value === rawValue} onChange={handleChange} {...segmentedOption} /> diff --git a/tests/__snapshots__/index.spec.tsx.snap b/tests/__snapshots__/index.spec.tsx.snap index 95de403..dd56eb3 100644 --- a/tests/__snapshots__/index.spec.tsx.snap +++ b/tests/__snapshots__/index.spec.tsx.snap @@ -110,7 +110,7 @@ exports[`rc-segmented render segmented ok 1`] = `
`; -exports[`rc-segmented render segmented with CSSMotion 1`] = ` +exports[`rc-segmented render segmented with CSSMotion basic 1`] = `
diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index f9aa2f0..a7a0392 100644 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { render, act, fireEvent } from '@testing-library/react'; import Segmented from '../src'; -import type { SegmentedValue } from '../src'; jest.mock('rc-motion/lib/util/motion', () => { return { @@ -24,6 +23,15 @@ describe('rc-segmented', () => { }); } + function exceptThumbHaveStyle(container: HTMLElement, matchStyle: object) { + const styleText = container + .querySelector('.rc-segmented-thumb') + ?.getAttribute('data-test-style'); + const style = JSON.parse(styleText!) || {}; + + expect(style).toMatchObject(matchStyle); + } + beforeEach(() => { jest.useFakeTimers(); }); @@ -260,105 +268,133 @@ describe('rc-segmented', () => { container.querySelector('.rc-segmented-item-selected')?.textContent, ).toContain('Web3'); - // Motion end - fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); - act(() => { - jest.runAllTimers(); - }); - // change it strangely fireEvent.change(container.querySelector('.control')!, { target: { value: 'Web4' }, }); - // invalid changes - expect( - container.querySelector('.rc-segmented-item-selected')?.textContent, - ).toContain('Web3'); + // invalid changes: Should not active any item to make sure it's single source of truth + expect(container.querySelector('.rc-segmented-item-selected')).toBeFalsy(); }); - it('render segmented with CSSMotion', () => { - const handleValueChange = jest.fn(); - const { container, asFragment } = render( - handleValueChange(value)} - />, - ); - expect(asFragment().firstChild).toMatchSnapshot(); - - expectMatchChecked(container, [true, false, false]); - expect(container.querySelectorAll('.rc-segmented-item')[0]).toHaveClass( - 'rc-segmented-item-selected', - ); + describe('render segmented with CSSMotion', () => { + it('basic', () => { + const handleValueChange = jest.fn(); + const { container, asFragment } = render( + handleValueChange(value)} + />, + ); + expect(asFragment().firstChild).toMatchSnapshot(); - fireEvent.click(container.querySelectorAll('.rc-segmented-item-input')[2]); - expect(handleValueChange).toBeCalledWith('Web3'); - expectMatchChecked(container, [false, false, true]); + expectMatchChecked(container, [true, false, false]); + expect(container.querySelectorAll('.rc-segmented-item')[0]).toHaveClass( + 'rc-segmented-item-selected', + ); - expect(container.querySelectorAll('.rc-segmented-thumb')[0]).toHaveClass( - 'rc-segmented-thumb-motion', - ); + // >>> Click: Web3 + fireEvent.click( + container.querySelectorAll('.rc-segmented-item-input')[2], + ); + expect(handleValueChange).toBeCalledWith('Web3'); + expectMatchChecked(container, [false, false, true]); - // thumb appeared at `iOS` - expect(container.querySelectorAll('.rc-segmented-thumb')[0]).toHaveStyle({ - transform: 'translateX(0px)', - width: '62px', - }); + expect(container.querySelectorAll('.rc-segmented-thumb')[0]).toHaveClass( + 'rc-segmented-thumb-motion', + ); - // Motion => active - act(() => { - jest.runAllTimers(); - }); + // thumb appeared at `iOS` + exceptThumbHaveStyle(container, { + '--thumb-start-left': '0px', + '--thumb-start-width': '62px', + }); + + // Motion => active + act(() => { + jest.runAllTimers(); + }); + + // Motion enter end + fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); + act(() => { + jest.runAllTimers(); + }); + + // thumb should disappear + expect(container.querySelector('.rc-segmented-thumb')).toBeFalsy(); + + // >>> Click: Android + fireEvent.click( + container.querySelectorAll('.rc-segmented-item-input')[1], + ); + expect(handleValueChange).toBeCalledWith('Android'); + expectMatchChecked(container, [false, true, false]); - // Motion enter end - fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); - act(() => { - jest.runAllTimers(); - }); + // thumb should move + expect(container.querySelector('.rc-segmented-thumb')).toHaveClass( + 'rc-segmented-thumb-motion', + ); - // Motion leave end - fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); - act(() => { - jest.runAllTimers(); + // thumb appeared at `Web3` + exceptThumbHaveStyle(container, { + '--thumb-start-left': '180px', + '--thumb-start-width': '76px', + }); + + // Motion appear end + act(() => { + jest.runAllTimers(); + }); + exceptThumbHaveStyle(container, { + '--thumb-active-left': '62px', + '--thumb-active-width': '118px', + }); + fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); + act(() => { + jest.runAllTimers(); + }); + + // thumb should disappear + expect(container.querySelector('.rc-segmented-thumb')).toBeFalsy(); }); - // thumb should disappear - expect(container.querySelector('.rc-segmented-thumb')).toBeFalsy(); + it('quick switch', () => { + const { container } = render( + , + ); - // change selection again - fireEvent.click(container.querySelectorAll('.rc-segmented-item-input')[1]); - expect(handleValueChange).toBeCalledWith('Android'); - expectMatchChecked(container, [false, true, false]); + // >>> Click: Web3 + fireEvent.click( + container.querySelectorAll('.rc-segmented-item-input')[2], + ); - // thumb should move - expect(container.querySelector('.rc-segmented-thumb')).toHaveClass( - 'rc-segmented-thumb-motion', - ); + // Motion to active + act(() => { + jest.runAllTimers(); + }); + expect(container.querySelector('.rc-segmented-thumb')).toHaveClass( + 'rc-segmented-thumb-motion-appear-active', + ); - // thumb appeared at `Web3` - expect(container.querySelector('.rc-segmented-thumb')).toHaveStyle({ - transform: 'translateX(180px)', - width: '76px', - }); + exceptThumbHaveStyle(container, { + '--thumb-active-left': '180px', + '--thumb-active-width': '76px', + }); - // Motion enter end - act(() => { - jest.runAllTimers(); - }); - fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); - act(() => { - jest.runAllTimers(); - }); + // >>> Click: IOS + fireEvent.click( + container.querySelectorAll('.rc-segmented-item-input')[0], + ); - // Motion leave end - fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); - act(() => { - jest.runAllTimers(); + exceptThumbHaveStyle(container, { + '--thumb-active-left': '0px', + '--thumb-active-width': '62px', + }); }); - - // thumb should disappear - expect(container.querySelector('.rc-segmented-thumb')).toBeFalsy(); }); it('render segmented with options null/undefined', () => { diff --git a/tests/setup.ts b/tests/setup.ts index 8fa23d1..b9329bd 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -13,7 +13,9 @@ Object.defineProperties(window.HTMLElement.prototype, { offsetLeft: { get() { let offsetLeft = 0; - const childList: HTMLElement[] = Array.from(this.parentNode.children); + const childList: HTMLElement[] = Array.from( + (this.parentNode as HTMLElement).querySelectorAll('.rc-segmented-item'), + ); for (let i = 0; i < childList.length; i++) { const child = childList[i]; const lastChild = childList[i - 1];