-
Notifications
You must be signed in to change notification settings - Fork 199
feat: unstable_ExpressiveSpinner #8613
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| .host { | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| inline-size: 100%; | ||
| block-size: 100%; | ||
| color: var(--vkui--color_icon_medium); | ||
| will-change: contents; | ||
| } | ||
|
|
||
| .noColor { | ||
| color: currentColor; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { withCartesian } from '@project-tools/storybook-addon-cartesian'; | ||
| import type { Meta, StoryObj } from '@storybook/react'; | ||
| import { CanvasFullLayout } from '../../../storybook/constants'; | ||
| import { createStoryParameters } from '../../../testing/storybook/createStoryParameters'; | ||
| import { type MaterialSpinnerProps, Spinner } from './Spinner'; | ||
|
|
||
| const story: Meta<MaterialSpinnerProps> = { | ||
| title: 'Blocks/Spinner/Expressive', | ||
| component: Spinner, | ||
| parameters: createStoryParameters('Spinner', CanvasFullLayout), | ||
| decorators: [withCartesian], | ||
| }; | ||
|
|
||
| export default story; | ||
|
|
||
| type Story = StoryObj<MaterialSpinnerProps>; | ||
|
|
||
| export const Playground: Story = {}; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { baselineComponent } from '../../../testing/utils'; | ||
| import { Spinner } from './Spinner'; | ||
|
|
||
| describe('Spinner', () => { | ||
| baselineComponent((props) => <Spinner disableAnimation {...props} />); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| 'use client'; | ||
|
|
||
| import { classNames, hasReactNode } from '@vkontakte/vkjs'; | ||
| import { usePlatform } from '../../../hooks/usePlatform'; | ||
| import * as shapes from '../../../lib/material/shapes/shapes'; | ||
| import { animationVisibilityDelayStyles } from '../../../styles/animationVisibilityDelay'; | ||
| import { RootComponent } from '../../RootComponent/RootComponent'; | ||
| import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden'; | ||
| import { Spinner as SimpleSpinner, type SpinnerProps } from '../Spinner'; | ||
| import { IconMaterial } from './icons'; | ||
| import styles from './Spinner.module.css'; | ||
| import stylesDelay from '../../../styles/animationVisibilityDelay.module.css'; | ||
|
|
||
| const iconSizeMap = { | ||
| s: 16, | ||
| m: 24, | ||
| l: 32, | ||
| xl: 44, | ||
| } as const; | ||
|
|
||
| const defaultShapesList = [ | ||
| shapes.softBurstParams, | ||
| shapes.cookie9Params, | ||
| shapes.pentagonParams, | ||
| shapes.pillParams, | ||
| shapes.sunnyParams, | ||
| shapes.cookie4Params, | ||
| shapes.ovalParams, | ||
| ] as const; | ||
|
|
||
| export interface MaterialSpinnerProps extends SpinnerProps { | ||
| /** | ||
| * Последовательность форм между которыми будет происходить анимация. | ||
| */ | ||
| polygons?: readonly [shapes.ShapeParameters, shapes.ShapeParameters, ...shapes.ShapeParameters[]]; | ||
| } | ||
|
|
||
| function MaterialSpinner({ | ||
| polygons = defaultShapesList, | ||
| size = 'm', | ||
| children = 'Загружается...', | ||
| disableAnimation = false, | ||
| noColor = false, | ||
| visibilityDelay, | ||
| ...restProps | ||
| }: MaterialSpinnerProps) { | ||
| const iconSize = iconSizeMap[size]; | ||
|
|
||
| return ( | ||
| <RootComponent | ||
| Component="span" | ||
| role="status" | ||
| {...restProps} | ||
| baseClassName={classNames( | ||
| styles.host, | ||
| noColor && styles.noColor, | ||
| visibilityDelay && stylesDelay.visibilityDelay, | ||
| )} | ||
| baseStyle={animationVisibilityDelayStyles(visibilityDelay)} | ||
| > | ||
| <IconMaterial size={iconSize} polygons={polygons} disableAnimation={disableAnimation} /> | ||
| {hasReactNode(children) && <VisuallyHidden>{children}</VisuallyHidden>} | ||
| </RootComponent> | ||
| ); | ||
| } | ||
|
|
||
| export function Spinner(props: SpinnerProps) { | ||
| const platform = usePlatform(); | ||
|
|
||
| const Component = platform === 'ios' ? SimpleSpinner : MaterialSpinner; | ||
|
|
||
| return <Component {...props} />; | ||
| } | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| export const unstable_ExpressiveSpinner = Spinner; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Вроде обычно приставку |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| 'use client'; | ||
|
|
||
| import * as React from 'react'; | ||
| import { useAnimationFrame } from '../../../hooks/useAnimationFrame'; | ||
| import { useReducedMotion } from '../../../lib/animation'; | ||
| import * as shapes from '../../../lib/material/shapes/shapes'; | ||
| import { interpolate } from '../../../lib/svg/path/interpolate'; | ||
| import { svgPathToString } from '../../../lib/svg/path/path'; | ||
| import * as operation from '../../../lib/svg/path/transform'; | ||
| import { SvgIcon } from '../SvgIcon'; | ||
|
|
||
| interface IconMaterialProps { | ||
| /** | ||
| * Список форм. | ||
| */ | ||
| polygons: readonly shapes.ShapeParameters[]; | ||
| /** | ||
| * Размер иконки. | ||
| */ | ||
| size: number; | ||
| /** | ||
| * Отключение анимации. | ||
| */ | ||
| disableAnimation: boolean; | ||
| } | ||
|
|
||
| export function IconMaterial(props: IconMaterialProps) { | ||
| return ( | ||
| <SvgIcon size={props.size}> | ||
| <IconMaterialPath {...props} /> | ||
| </SvgIcon> | ||
| ); | ||
| } | ||
|
|
||
| const globalRotationDuration = 4666; | ||
| const morphDuration = 200; | ||
| const morphInterval = 650; | ||
| const fullRotation = 360; | ||
| const quarterRotation = fullRotation / 4; | ||
|
|
||
| function calcProgress(startTime: number, time: number, duration: number, delay = 0) { | ||
| const fullDuration = duration + delay; | ||
|
|
||
| const timeProgress = fullDuration * (((time - startTime) % fullDuration) / fullDuration); | ||
|
|
||
| if (timeProgress < delay) { | ||
| return 0; | ||
| } | ||
|
|
||
| return (timeProgress - delay) / duration; | ||
| } | ||
|
|
||
| function IconMaterialPath({ size, polygons, disableAnimation }: IconMaterialProps) { | ||
| const ref = React.useRef<SVGPathElement>(null); | ||
|
|
||
| const morphSequence = React.useMemo(() => { | ||
| function getShape(index: number, size: number) { | ||
| return shapes.shapeWithRotate(polygons[index], size); | ||
| } | ||
|
|
||
| return new Array(polygons.length).fill(0).map((_, index) => { | ||
| return interpolate(getShape(index, size), getShape((index + 1) % polygons.length, size), { | ||
| maxSegmentLength: 2, | ||
| }); | ||
| }); | ||
| }, [size, polygons]); | ||
|
|
||
| const initialPath = React.useMemo(() => svgPathToString(morphSequence[0](0)), [morphSequence]); | ||
|
|
||
| const callback = React.useCallback( | ||
| (time: number) => { | ||
| const rotationAnimationProgress = calcProgress(0, time, globalRotationDuration); | ||
| const globalRotation = rotationAnimationProgress * fullRotation; | ||
|
|
||
| // TODO: spring({ | ||
| // dampingRatio: 0.6, | ||
| // stiffness: 200, | ||
| // visibilityThreshold: 0.1, | ||
| // }) | ||
| const morphProgress = calcProgress(0, time, morphDuration, morphInterval); | ||
|
|
||
| const roundMorphIndex = Math.floor(time / (morphDuration + morphInterval)); | ||
|
|
||
| const currentMorphIndex = roundMorphIndex % morphSequence.length; | ||
|
|
||
| const morphRotationTargetAngle = (roundMorphIndex * quarterRotation) % fullRotation; | ||
| const rotation = morphProgress * quarterRotation + morphRotationTargetAngle + globalRotation; | ||
|
|
||
| const morphFn = morphSequence[currentMorphIndex]; | ||
| const morph = morphFn(morphProgress); | ||
|
|
||
| ref.current!.setAttribute( | ||
| 'd', | ||
| svgPathToString(operation.rotate(morph, size / 2, size / 2, rotation)), | ||
| ); | ||
| }, | ||
| [morphSequence, size], | ||
| ); | ||
|
|
||
| const isReducedMotion = useReducedMotion(); | ||
| useAnimationFrame(callback, disableAnimation || isReducedMotion); | ||
|
|
||
| return <path ref={ref} fill="currentColor" d={initialPath}></path>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { type HasRootRef } from '../../types'; | ||
| import { RootComponent } from '../RootComponent/RootComponent'; | ||
|
|
||
| /** | ||
| * Возвращает класс для иконки. | ||
| */ | ||
| export function iconClassName(size: number) { | ||
| return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`; | ||
| } | ||
|
|
||
| interface SvgIconProps extends React.ComponentProps<'svg'>, HasRootRef<SVGElement> { | ||
| /** | ||
| * Размер иконки. | ||
| */ | ||
| size: number; | ||
| } | ||
|
|
||
| export function SvgIcon({ size, children, ...restProps }: SvgIconProps) { | ||
| return ( | ||
| <RootComponent | ||
| Component="svg" | ||
| baseClassName={iconClassName(size)} | ||
| aria-hidden="true" | ||
| width={size} | ||
| height={size} | ||
| {...restProps} | ||
| > | ||
| {children} | ||
| </RootComponent> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,57 +1,54 @@ | ||
| import type * as React from 'react'; | ||
|
|
||
| function iconClassName(size: number) { | ||
| return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`; | ||
| } | ||
| import * as React from 'react'; | ||
| import { SvgIcon } from './SvgIcon'; | ||
|
|
||
| export function Icon16Spinner({ children }: React.PropsWithChildren) { | ||
| return ( | ||
| <svg className={iconClassName(16)} aria-hidden="true" width="16" height="16"> | ||
| <SvgIcon size={16}> | ||
| <path | ||
| fill="currentColor" | ||
| d="M8 3.25a4.75 4.75 0 0 0-4.149 7.065.75.75 0 1 1-1.31.732A6.25 6.25 0 1 1 8 14.25a.75.75 0 0 1 .001-1.5 4.75 4.75 0 1 0 0-9.5Z" | ||
| > | ||
| {children} | ||
| </path> | ||
| </svg> | ||
| </SvgIcon> | ||
| ); | ||
| } | ||
|
|
||
| export function Icon24Spinner({ children }: React.PropsWithChildren) { | ||
| return ( | ||
| <svg className={iconClassName(24)} aria-hidden="true" width="24" height="24"> | ||
| <SvgIcon size={24}> | ||
| <path | ||
| fill="currentColor" | ||
| d="M16.394 5.077A8.2 8.2 0 0 0 4.58 15.49a.9.9 0 0 1-1.628.767A10 10 0 1 1 12 22a.9.9 0 0 1 0-1.8 8.2 8.2 0 0 0 4.394-15.123" | ||
| > | ||
| {children} | ||
| </path> | ||
| </svg> | ||
| </SvgIcon> | ||
| ); | ||
| } | ||
|
|
||
| export function Icon32Spinner({ children }: React.PropsWithChildren) { | ||
| return ( | ||
| <svg className={iconClassName(32)} aria-hidden="true" width="32" height="32"> | ||
| <SvgIcon size={32}> | ||
| <path | ||
| fill="currentColor" | ||
| d="M16 32a1.5 1.5 0 0 1 0-3c7.18 0 13-5.82 13-13S23.18 3 16 3 3 8.82 3 16c0 1.557.273 3.074.8 4.502A1.5 1.5 0 1 1 .986 21.54 16 16 0 0 1 0 16C0 7.163 7.163 0 16 0s16 7.163 16 16-7.163 16-16 16" | ||
| > | ||
| {children} | ||
| </path> | ||
| </svg> | ||
| </SvgIcon> | ||
| ); | ||
| } | ||
|
|
||
| export function Icon44Spinner({ children }: React.PropsWithChildren) { | ||
| return ( | ||
| <svg className={iconClassName(44)} aria-hidden="true" width="44" height="44"> | ||
| <SvgIcon size={44}> | ||
| <path | ||
| fill="currentColor" | ||
| d="M22 44a1.5 1.5 0 0 1 0-3c10.493 0 19-8.507 19-19S32.493 3 22 3 3 11.507 3 22c0 2.208.376 4.363 1.103 6.397a1.5 1.5 0 1 1-2.825 1.01A22 22 0 0 1 0 22C0 9.85 9.85 0 22 0s22 9.85 22 22-9.85 22-22 22" | ||
| > | ||
| {children} | ||
| </path> | ||
| </svg> | ||
| </SvgIcon> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,45 @@ | ||||||||||||||
| import * as React from 'react'; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Обертка над `requestAnimationFrame` | ||||||||||||||
| * | ||||||||||||||
| * ```ts | ||||||||||||||
| * const animate = React.useCallback((delta: number) => { | ||||||||||||||
| * console.log('Delta:', delta); | ||||||||||||||
| * }, []); | ||||||||||||||
| * | ||||||||||||||
| * useAnimationFrame(animate); | ||||||||||||||
| * ``` | ||||||||||||||
| * | ||||||||||||||
| * @param callback Функция, которая будет вызываться каждый раз при обновлении анимации. | ||||||||||||||
| * Принимает параметр `delta` - время в миллисекундах, прошедшее с первого кадра анимации. | ||||||||||||||
| */ | ||||||||||||||
| export function useAnimationFrame(callback: (delta: number) => void, disableAnimation = false) { | ||||||||||||||
| const handleRef = React.useRef<number>(undefined); | ||||||||||||||
| const startTimestampRef = React.useRef<number>(undefined); | ||||||||||||||
| const callbackRef = React.useRef(callback); | ||||||||||||||
|
|
||||||||||||||
| React.useEffect(() => { | ||||||||||||||
| callbackRef.current = callback; | ||||||||||||||
| }, [callback]); | ||||||||||||||
|
Comment on lines
+20
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| React.useEffect(() => { | ||||||||||||||
| if (disableAnimation) { | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const animate = (timestamp: number) => { | ||||||||||||||
| if (startTimestampRef.current === undefined) { | ||||||||||||||
| startTimestampRef.current = timestamp; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const delta = timestamp - startTimestampRef.current; | ||||||||||||||
| callbackRef.current(delta); | ||||||||||||||
|
|
||||||||||||||
| handleRef.current = requestAnimationFrame(animate); | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| handleRef.current = requestAnimationFrame(animate); | ||||||||||||||
| return () => cancelAnimationFrame(handleRef.current!); | ||||||||||||||
| }, [disableAnimation]); | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import * as array from './array'; | ||
|
|
||
| it('array.copy', () => { | ||
| const originalArray = [1, 2, 3]; | ||
| const copiedArray = array.copy(originalArray); | ||
|
|
||
| expect(copiedArray).toStrictEqual(originalArray); | ||
| expect(copiedArray).not.toBe(originalArray); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.