From 39d226703fc1fe1bfe2cb8aac9c792b5434437fd Mon Sep 17 00:00:00 2001 From: curry Date: Thu, 5 Sep 2024 13:54:23 +0800 Subject: [PATCH] feat: add vertical props (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 梁朝飞 --- assets/index.less | 61 +++++++---- docs/demo/basic.tsx | 11 +- src/MotionThumb.tsx | 129 +++++++++++++++++------- src/index.tsx | 9 +- tests/__snapshots__/index.test.tsx.snap | 124 +++++++++++++++++++++++ tests/index.test.tsx | 95 +++++++++++++++++ 6 files changed, 367 insertions(+), 62 deletions(-) diff --git a/assets/index.less b/assets/index.less index 801039c..e3589cf 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,16 +1,22 @@ @segmented-prefix-cls: rc-segmented; +@disabled-color: fade(#000, 25%); +@selected-bg-color: white; +@text-color: #262626; +@transition-duration: 0.3s; +@transition-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1); + .segmented-disabled-item() { &, &:hover, &:focus { - color: fade(#000, 25%); + color: @disabled-color; cursor: not-allowed; } } .segmented-item-selected() { - background-color: white; + background-color: @selected-bg-color; } .@{segmented-prefix-cls} { @@ -21,9 +27,9 @@ &-group { position: relative; display: flex; + flex-direction: row; align-items: stretch; - justify-items: flex-start; - + justify-content: flex-start; width: 100%; border-radius: 2px; } @@ -32,19 +38,18 @@ position: relative; min-height: 28px; padding: 4px 10px; - color: fade(#000, 85%); text-align: center; cursor: pointer; &-selected { .segmented-item-selected(); - color: #262626; + color: @text-color; } &:hover, &:focus { - color: #262626; + color: @text-color; } &-disabled { @@ -60,7 +65,6 @@ position: absolute; top: 0; left: 0; - width: 0; height: 0; opacity: 0; @@ -68,29 +72,44 @@ } } - // disabled styles - &-disabled &-item, - &-disabled &-item:hover, - &-disabled &-item:focus { - .segmented-disabled-item(); - } - &-thumb { .segmented-item-selected(); - position: absolute; - // top: 0; - // left: 0; width: 0; height: 100%; padding: 4px 0; + transition: transform @transition-duration @transition-timing-function, + width @transition-duration @transition-timing-function; + } + + &-vertical &-group { + flex-direction: column; + } + + &-vertical &-item { + width: 100%; + text-align: left; + } + + &-vertical &-thumb { + width: 100%; + height: 0; + padding: 0 4px; + transition: transform @transition-duration @transition-timing-function, + height @transition-duration @transition-timing-function; + } + + // disabled styles + &-disabled &-item, + &-disabled &-item:hover, + &-disabled &-item:focus { + .segmented-disabled-item(); } - // 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); + transition: transform @transition-duration @transition-timing-function, + width @transition-duration @transition-timing-function; will-change: transform, width; } diff --git a/docs/demo/basic.tsx b/docs/demo/basic.tsx index 649a853..9be5161 100644 --- a/docs/demo/basic.tsx +++ b/docs/demo/basic.tsx @@ -1,6 +1,6 @@ -import '../../assets/style.less'; -import React from 'react'; import Segmented from 'rc-segmented'; +import React from 'react'; +import '../../assets/style.less'; export default function App() { return ( @@ -11,6 +11,13 @@ export default function App() { onChange={(value) => console.log(value, typeof value)} /> +
+ console.log(value, typeof value)} + /> +
- targetElement - ? { - left: targetElement.offsetLeft, - right: - (targetElement.parentElement!.clientWidth as number) - - targetElement.clientWidth - - targetElement.offsetLeft, - width: targetElement.clientWidth, - } - : null; + vertical?: boolean, +): ThumbReact => { + if (!targetElement) return null; + + const style: ThumbReact = { + left: targetElement.offsetLeft, + right: + (targetElement.parentElement!.clientWidth as number) - + targetElement.clientWidth - + targetElement.offsetLeft, + width: targetElement.clientWidth, + top: targetElement.offsetTop, + bottom: + (targetElement.parentElement!.clientHeight as number) - + targetElement.clientHeight - + targetElement.offsetTop, + height: targetElement.clientHeight, + }; + + if (vertical) { + // Adjusts positioning and size for vertical layout by setting horizontal properties to 0 and using vertical properties from the style object. + return { + left: 0, + right: 0, + width: 0, + top: style.top, + bottom: style.bottom, + height: style.height, + }; + } + + return { + left: style.left, + right: style.right, + width: style.width, + top: 0, + bottom: 0, + height: 0, + }; +}; -const toPX = (value: number) => +const toPX = (value: number | undefined): string | undefined => value !== undefined ? `${value}px` : undefined; export default function MotionThumb(props: MotionThumbInterface) { @@ -49,6 +82,7 @@ export default function MotionThumb(props: MotionThumbInterface) { onMotionStart, onMotionEnd, direction, + vertical = false, } = props; const thumbRef = React.useRef(null); @@ -57,11 +91,9 @@ export default function MotionThumb(props: MotionThumbInterface) { // =========================== Effect =========================== const findValueElement = (val: SegmentedValue) => { const index = getValueIndex(val); - const ele = containerRef.current?.querySelectorAll( `.${prefixCls}-item`, )[index]; - return ele?.offsetParent && ele; }; @@ -73,8 +105,8 @@ export default function MotionThumb(props: MotionThumbInterface) { const prev = findValueElement(prevValue); const next = findValueElement(value); - const calcPrevStyle = calcThumbStyle(prev); - const calcNextStyle = calcThumbStyle(next); + const calcPrevStyle = calcThumbStyle(prev, vertical); + const calcNextStyle = calcThumbStyle(next, vertical); setPrevValue(value); setPrevStyle(calcPrevStyle); @@ -88,34 +120,59 @@ export default function MotionThumb(props: MotionThumbInterface) { } }, [value]); - const thumbStart = React.useMemo( - () => - direction === 'rtl' - ? toPX(-(prevStyle?.right as number)) - : toPX(prevStyle?.left as number), - [direction, prevStyle], - ); - const thumbActive = React.useMemo( - () => - direction === 'rtl' - ? toPX(-(nextStyle?.right as number)) - : toPX(nextStyle?.left as number), - [direction, nextStyle], - ); + const thumbStart = React.useMemo(() => { + if (vertical) { + return toPX(prevStyle?.top ?? 0); + } + + if (direction === 'rtl') { + return toPX(-(prevStyle?.right as number)); + } + + return toPX(prevStyle?.left as number); + }, [vertical, direction, prevStyle]); + + const thumbActive = React.useMemo(() => { + if (vertical) { + return toPX(nextStyle?.top ?? 0); + } + + if (direction === 'rtl') { + return toPX(-(nextStyle?.right as number)); + } + + return toPX(nextStyle?.left as number); + }, [vertical, direction, nextStyle]); // =========================== Motion =========================== const onAppearStart = () => { + if (vertical) { + return { + transform: 'translateY(var(--thumb-start-top))', + height: 'var(--thumb-start-height)', + }; + } + return { - transform: `translateX(var(--thumb-start-left))`, - width: `var(--thumb-start-width)`, + transform: 'translateX(var(--thumb-start-left))', + width: 'var(--thumb-start-width)', }; }; + const onAppearActive = () => { + if (vertical) { + return { + transform: 'translateY(var(--thumb-active-top))', + height: 'var(--thumb-active-height)', + }; + } + return { - transform: `translateX(var(--thumb-active-left))`, - width: `var(--thumb-active-width)`, + transform: 'translateX(var(--thumb-active-left))', + width: 'var(--thumb-active-width)', }; }; + const onVisibleChanged = () => { setPrevStyle(null); setNextStyle(null); @@ -144,6 +201,10 @@ export default function MotionThumb(props: MotionThumbInterface) { '--thumb-start-width': toPX(prevStyle?.width), '--thumb-active-left': thumbActive, '--thumb-active-width': toPX(nextStyle?.width), + '--thumb-start-top': thumbStart, + '--thumb-start-height': toPX(prevStyle?.height), + '--thumb-active-top': thumbActive, + '--thumb-active-height': toPX(nextStyle?.height), } as React.CSSProperties; // It's little ugly which should be refactor when @umi/test update to latest jsdom diff --git a/src/index.tsx b/src/index.tsx index 9c0fa27..3ef6f6c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -39,6 +39,7 @@ export interface SegmentedProps prefixCls?: string; direction?: 'ltr' | 'rtl'; motionName?: string; + vertical?: boolean; } function getValidTitle(option: SegmentedLabeledOption) { @@ -56,13 +57,11 @@ function normalizeOptions(options: SegmentedOptions): SegmentedLabeledOption[] { return options.map((option) => { if (typeof option === 'object' && option !== null) { const validTitle = getValidTitle(option); - return { ...option, title: validTitle, }; } - return { label: option?.toString(), title: option?.toString(), @@ -97,7 +96,6 @@ const InternalSegmentedOption: React.FC<{ if (disabled) { return; } - onChange(event, value); }; @@ -131,6 +129,7 @@ const Segmented = React.forwardRef( const { prefixCls = 'rc-segmented', direction, + vertical, options = [], disabled, defaultValue, @@ -168,9 +167,7 @@ const Segmented = React.forwardRef( if (disabled) { return; } - setRawValue(val); - onChange?.(val); }; @@ -186,6 +183,7 @@ const Segmented = React.forwardRef( { [`${prefixCls}-rtl`]: direction === 'rtl', [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-vertical`]: vertical, }, className, )} @@ -193,6 +191,7 @@ const Segmented = React.forwardRef( >
`; + +exports[`rc-segmented should render vertical segmented 1`] = ` +
+
+ + + +
+
+`; + +exports[`rc-segmented should render vertical segmented and handle thumb animations correctly 1`] = ` +
+
+ + + +
+
+`; diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 59fb1b1..d94e47d 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -555,4 +555,99 @@ describe('rc-segmented', () => { offsetParentSpy.mockRestore(); }); + + it('should render vertical segmented', () => { + const { container, asFragment } = render( + , + ); + + expect(asFragment().firstChild).toMatchSnapshot(); + expect(container.querySelector('.rc-segmented')).toHaveClass( + 'rc-segmented-vertical', + ); + expectMatchChecked(container, [true, false, false]); + }); + + it('should render vertical segmented and handle thumb animations correctly', () => { + const offsetParentSpy = jest + .spyOn(HTMLElement.prototype, 'offsetParent', 'get') + .mockImplementation(() => { + return container; + }); + const handleValueChange = jest.fn(); + const { container, asFragment } = render( + handleValueChange(value)} + />, + ); + + // Snapshot test + expect(asFragment().firstChild).toMatchSnapshot(); + expect(container.querySelector('.rc-segmented')).toHaveClass( + 'rc-segmented-vertical', + ); + expectMatchChecked(container, [true, false, false]); + + // Click: Web + fireEvent.click(container.querySelectorAll('.rc-segmented-item-input')[2]); + expect(handleValueChange).toBeCalledWith('Web'); + expectMatchChecked(container, [false, false, true]); + + // Thumb should appear at `iOS` + exceptThumbHaveStyle(container, { + '--thumb-start-top': '0px', + '--thumb-start-height': '0px', + }); + + // 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]); + + // Thumb should move + expect(container.querySelector('.rc-segmented-thumb')).toHaveClass( + 'rc-segmented-thumb-motion', + ); + + // Thumb appeared at `Web` + exceptThumbHaveStyle(container, { + '--thumb-start-top': '0px', + '--thumb-start-height': '0px', + }); + + // Motion appear end + act(() => { + jest.runAllTimers(); + }); + exceptThumbHaveStyle(container, { + '--thumb-active-top': '0px', + '--thumb-active-height': '0px', + }); + + fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); + act(() => { + jest.runAllTimers(); + }); + + // Thumb should disappear + expect(container.querySelector('.rc-segmented-thumb')).toBeFalsy(); + + offsetParentSpy.mockRestore(); + }); });