REFACTOR: Ripple effect

TODO: Segmented buttons styles
This commit is contained in:
doryan04 2024-02-09 21:18:20 +04:00
parent 6ee10e0fa2
commit 3f027d9e51
17 changed files with 249 additions and 279 deletions

View File

@ -27,7 +27,7 @@ module.exports = {
plugins: ['react'], plugins: ['react'],
rules: { rules: {
// Possible errors // Possible errors
'no-console': 'warn', 'no-console': 'off',
// Best practices // Best practices
'dot-notation': 'error', 'dot-notation': 'error',
'no-else-return': 'error', 'no-else-return': 'error',
@ -36,9 +36,10 @@ module.exports = {
// Stylistic // Stylistic
'array-bracket-spacing': 'error', 'array-bracket-spacing': 'error',
'computed-property-spacing': ['error', 'never'], 'computed-property-spacing': ['error', 'never'],
'@typescript-eslint/no-unused-vars': 'warn',
curly: 'error', curly: 'error',
'no-lonely-if': 'error', 'no-lonely-if': 'error',
'no-unneeded-ternary': 'error', 'no-unneeded-ternary': 'warn',
'one-var-declaration-per-line': 'error', 'one-var-declaration-per-line': 'error',
quotes: [ quotes: [
'error', 'error',

View File

@ -2,5 +2,6 @@
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="ES6PreferShortImport" enabled="false" level="WARNING" enabled_by_default="false" /> <inspection_tool class="ES6PreferShortImport" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile> </profile>
</component> </component>

View File

@ -13,7 +13,7 @@ This repository is including and will be including components, enumerates in tab
- [X] Switches - [X] Switches
- [ ] Chips - [ ] Chips
- [x] Icon - [x] Icon
- [x] Ripples Effect - [x] Ripple Effect
- [ ] Dividers (WIP) - [ ] Dividers (WIP)
- [x] Badges - [x] Badges
- [ ] Select field - [ ] Select field

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { bool, string } from 'prop-types'; import { bool, string } from 'prop-types';
import { RippleArea } from '../../ripple/ripple-area'; import { RippleEffect } from '../../ripple/ripple-effect';
import { ButtonLayoutProps } from './button-layout.types'; import { ButtonLayoutProps } from './button-layout.types';
import useRippleEffect from '../../ripple/hooks/useRippleEffect'; import useRippleEffect from '../../ripple/hooks/useRippleEffect';
import React, { forwardRef, useId, useRef, useState } from 'react'; import React, { forwardRef, useId, useRef, useState } from 'react';
@ -26,7 +26,7 @@ export const ButtonLayout = forwardRef<HTMLButtonElement, ButtonLayoutProps>(
ref={ref} ref={ref}
> >
{props.children} {props.children}
<RippleArea <RippleEffect
callback={setIsActive} callback={setIsActive}
central={centralRipple} central={centralRipple}
ref={ripplesRef} ref={ripplesRef}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { RippleArea } from '../ripple/ripple-area'; import { RippleEffect } from '../ripple/ripple-effect';
import { CardActionAreaProps } from './card.types'; import { CardActionAreaProps } from './card.types';
import { forwardRef, useRef, useState } from 'react'; import { forwardRef, useRef, useState } from 'react';
import useRippleEffect from '../ripple/hooks/useRippleEffect'; import useRippleEffect from '../ripple/hooks/useRippleEffect';
@ -29,7 +29,7 @@ export const CardActionArea = forwardRef<HTMLDivElement, CardActionAreaProps>(
</div> </div>
<span className={'m3 m3-card-state-layer'} /> <span className={'m3 m3-card-state-layer'} />
{ripples && ( {ripples && (
<RippleArea <RippleEffect
callback={setIsActive} callback={setIsActive}
central={centralRipple} central={centralRipple}
ref={ripplesRef} ref={ripplesRef}

View File

@ -1,10 +1,10 @@
export { Card } from './card/card'; export { Card } from './card/card';
export { Icon } from './icon/icon'; export { Icon } from './icon/icon';
export { Badge } from './badge/badge'; export { Badge } from './badge/badge';
export { Ripple } from './ripple/ripple';
export { Divider } from './divider/divider'; export { Divider } from './divider/divider';
export { Container } from './container/container'; export { Container } from './container/container';
export { RippleArea } from './ripple/ripple-area'; export { RippleEffect } from './ripple/ripple-effect';
export { Ripples, Ripples } from './ripple/ripples';
export { FAB } from './button-components/fab/fab'; export { FAB } from './button-components/fab/fab';
export { Radio } from './input-components/radio/radio'; export { Radio } from './input-components/radio/radio';
export { Switch } from './input-components/switch/switch'; export { Switch } from './input-components/switch/switch';

View File

@ -2,7 +2,7 @@
import { bool } from 'prop-types'; import { bool } from 'prop-types';
import { CheckboxProps } from './checkbox.types'; import { CheckboxProps } from './checkbox.types';
import { RippleArea } from '../../ripple/ripple-area'; import { RippleEffect } from '../../ripple/ripple-effect';
import useRippleEffect from '../../ripple/hooks/useRippleEffect'; import useRippleEffect from '../../ripple/hooks/useRippleEffect';
import { CheckBoxLayout } from '../checkbox-layout/check-box-layout'; import { CheckBoxLayout } from '../checkbox-layout/check-box-layout';
import { import {
@ -45,7 +45,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
/> />
<span className={'m3 m3-checkbox-state'} /> <span className={'m3 m3-checkbox-state'} />
<span className={'m3 m3-checkbox-state-layer'} /> <span className={'m3 m3-checkbox-state-layer'} />
<RippleArea <RippleEffect
callback={setIsActive} callback={setIsActive}
central={centralRipple} central={centralRipple}
className={'m3-checkbox-ripple-layer'} className={'m3-checkbox-ripple-layer'}

View File

@ -2,7 +2,7 @@
import { bool, string } from 'prop-types'; import { bool, string } from 'prop-types';
import { RadioProps } from './radio.types'; import { RadioProps } from './radio.types';
import { RippleArea } from '../../ripple/ripple-area'; import { RippleEffect } from '../../ripple/ripple-effect';
import { forwardRef, useRef, useState } from 'react'; import { forwardRef, useRef, useState } from 'react';
import useRippleEffect from '../../ripple/hooks/useRippleEffect'; import useRippleEffect from '../../ripple/hooks/useRippleEffect';
import { CheckBoxLayout } from '../checkbox-layout/check-box-layout'; import { CheckBoxLayout } from '../checkbox-layout/check-box-layout';
@ -37,7 +37,7 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
cy={'50%'} cy={'50%'}
/> />
</svg> </svg>
<RippleArea <RippleEffect
callback={setIsActive} callback={setIsActive}
central={centralRipple} central={centralRipple}
className={'m3-checkbox-ripple-layer'} className={'m3-checkbox-ripple-layer'}

View File

@ -0,0 +1,115 @@
import React, {
ForwardedRef,
MutableRefObject,
useCallback,
useImperativeHandle,
useRef,
useState,
useTransition,
} from 'react';
import { Ripple } from '../ripple';
import { baseDOMRect, RippleContainer } from '../ripple.types';
import isEmpty, { rippleSizeCalculator } from '../utils/utils';
import { InteractionEventsType } from './useRippleEffect';
const RIPPLE_LIFETIME: number = 550;
const DEBOUNCE: number = 50;
const UseRippleBuilder = (
stateRef: MutableRefObject<boolean>,
rippleDomainRef: MutableRefObject<HTMLSpanElement>,
central: boolean,
forwardedRef: ForwardedRef<RippleContainer>,
) => {
const [ripples, setRipples] = useState({});
const [pending, transition] = useTransition();
const debounced = useRef<boolean>(false);
const uniqueKey = useRef<number>(0);
const endLifetimeRipple = (keyRipple: number) => {
if (!pending && !isEmpty(ripples)) {
transition(() => {
setRipples(prevRipples => {
delete prevRipples[keyRipple];
return prevRipples;
});
});
}
};
const createRipple = useCallback(
(
event: InteractionEventsType,
changeStateCallback: (state: boolean) => void,
): void => {
if (stateRef.current || debounced.current) {
return;
}
debounced.current = true;
setTimeout(() => {
debounced.current = false;
}, DEBOUNCE);
stateRef.current = true;
changeStateCallback(stateRef.current);
const rippleDomain: DOMRect | baseDOMRect = rippleDomainRef.current
? rippleDomainRef.current.getBoundingClientRect()
: {
width: 0,
height: 0,
left: 0,
top: 0,
};
const [rippleX, rippleY, rippleS]: Array<number> =
rippleSizeCalculator(event, rippleDomain, central);
const prevRipples = ripples;
prevRipples[uniqueKey.current] = (
<Ripple
endLifetime={endLifetimeRipple}
key={uniqueKey.current}
lifetime={RIPPLE_LIFETIME}
rippleKey={uniqueKey.current}
rippleS={rippleS}
rippleX={rippleX}
rippleY={rippleY}
/>
);
setRipples(prevRipples);
uniqueKey.current += 1;
},
[uniqueKey, debounced],
);
const removeRipple = (
event: InteractionEventsType,
changeStateCallback: (state: boolean) => void,
) => {
if (!stateRef.current) {
return;
}
stateRef.current = false;
changeStateCallback(stateRef.current);
};
useImperativeHandle(
forwardedRef,
() => ({
start: createRipple,
stop: removeRipple,
current: rippleDomainRef,
}),
[createRipple, removeRipple],
);
return ripples;
};
export default UseRippleBuilder;

View File

@ -1,131 +0,0 @@
'use client';
import React, {
forwardRef,
ReactElement,
useId,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { Ripples, Ripple } from './ripples';
import { RippleAreaProps } from './ripple.types';
import { InteractionEventsType } from './hooks/useRippleEffect';
const TIMEOUT: number = 550;
const rippleAreaContext = React.createContext(false);
const RippleArea = forwardRef(
({ central = false, ...props }: RippleAreaProps, ref) => {
const [ripples, setRipples] = useState<Array<ReactElement>>([]),
rippleDomain = useRef(null),
clicked = useRef<boolean>(false),
uniqueKey = useRef<number>(0),
uniqueId = useId();
const extraClassStyles =
`m3 m3-ripple-domain ${props.className ?? ''}`.trimEnd();
const createRipple = (
event: InteractionEventsType,
cb: (state: boolean) => void,
): void => {
clicked.current = true;
cb(clicked.current);
const rippleDomainChar = rippleDomain.current
? rippleDomain.current.getBoundingClientRect()
: {
width: 0,
height: 0,
left: 0,
top: 0,
};
const rippleX: number = !central
? event.clientX - rippleDomainChar.left
: rippleDomainChar.width / 2,
rippleY: number = !central
? event.clientY - rippleDomainChar.top
: rippleDomainChar.height / 2,
rippleSizeX: number =
Math.max(
Math.abs(rippleDomainChar.width - rippleX),
rippleX,
) *
2 +
2,
rippleSizeY: number =
Math.max(
Math.abs(rippleDomainChar.height - rippleY),
rippleY,
) *
2 +
2,
rippleS: number = (rippleSizeX ** 2 + rippleSizeY ** 2) ** 0.5;
setRipples((prevRipples: Array<ReactElement>) => {
if (prevRipples.length === 0) {
return [
<Ripple
key={uniqueKey.current}
lifetime={TIMEOUT}
rippleS={rippleS}
rippleX={rippleX}
rippleY={rippleY}
/>,
];
}
const old = [...prevRipples];
old.push(
<Ripple
key={uniqueKey.current}
lifetime={TIMEOUT}
rippleS={rippleS}
rippleX={rippleX}
rippleY={rippleY}
/>,
);
return old;
});
uniqueKey.current += 1;
};
const removeRipple = (
_event: InteractionEventsType,
cb: (state: boolean) => void,
) => {
clicked.current = false;
cb(clicked.current);
setRipples((prevRipples: Array<ReactElement>) => {
if (prevRipples.length > 0) {
const old = [...prevRipples];
old.shift();
return old;
}
return prevRipples;
});
};
useImperativeHandle(
ref,
() => ({
start: createRipple,
stop: removeRipple,
}),
[createRipple, removeRipple],
);
return (
<span className={extraClassStyles} id={uniqueId} ref={rippleDomain}>
<rippleAreaContext.Provider value={clicked.current}>
<Ripples>{ripples}</Ripples>
</rippleAreaContext.Provider>
</span>
);
},
);
export { rippleAreaContext, RippleArea };

View File

@ -0,0 +1,30 @@
'use client';
import useRippleBuilder from './hooks/useRippleBuilder';
import React, { forwardRef, useId, useRef } from 'react';
import { RippleAreaProps, RippleContainer } from './ripple.types';
const rippleAreaContext = React.createContext(false);
const RippleEffect = forwardRef<RippleContainer, RippleAreaProps>(
({ central = false, ...props }, ref) => {
const uniqueId = useId(),
rippleDomain = useRef<HTMLSpanElement>(null),
clicked = useRef<boolean>(false),
ripples = useRippleBuilder(clicked, rippleDomain, central, ref);
return (
<span
className={`m3 m3-ripple-domain ${props.className ?? ''}`.trimEnd()}
id={uniqueId}
ref={rippleDomain}
>
<rippleAreaContext.Provider value={clicked.current}>
{Object.values(ripples)}
</rippleAreaContext.Provider>
</span>
);
},
);
export { rippleAreaContext, RippleEffect };

View File

@ -0,0 +1,38 @@
'use client';
import { RippleProps } from './ripple.types';
import { rippleAreaContext } from './ripple-effect';
import React, { useContext, useLayoutEffect, useState } from 'react';
const Ripple = ({
rippleX,
rippleY,
rippleS,
lifetime,
rippleKey,
endLifetime,
}: RippleProps) => {
const [classes, setClasses] = useState<string>('m3 ripple visible');
const rippleDomainContext = useContext(rippleAreaContext);
useLayoutEffect(() => {
if (endLifetime !== null && !rippleDomainContext) {
setClasses('m3 ripple');
setTimeout(() => endLifetime(rippleKey), lifetime);
}
}, [rippleDomainContext]);
return (
<span
className={classes}
style={{
left: -(rippleS / 2) + rippleX,
top: -(rippleS / 2) + rippleY,
width: rippleS,
aspectRatio: 1,
}}
/>
);
};
export { Ripple };

View File

@ -1,7 +1,28 @@
import { Dispatch, HTMLAttributes, ReactElement, SetStateAction } from 'react'; import {
Dispatch,
HTMLAttributes,
MutableRefObject,
SetStateAction,
} from 'react';
import { InteractionEventsType } from './hooks/useRippleEffect';
export interface RipplesProps extends HTMLAttributes<HTMLElement> { export type baseDOMRect = {
children?: ReactElement[]; width: number;
height: number;
left: number;
top: number;
};
export interface RippleContainer {
start: (
event: InteractionEventsType,
changeStateCallback: (state: boolean) => void,
) => void;
stop: (
event: InteractionEventsType,
changeStateCallback: (state: boolean) => void,
) => void;
current: MutableRefObject<HTMLSpanElement>;
} }
export interface RipplePropsForComponents<T> extends HTMLAttributes<T> { export interface RipplePropsForComponents<T> extends HTMLAttributes<T> {
@ -14,10 +35,10 @@ export interface RippleAreaProps extends HTMLAttributes<HTMLElement> {
} }
export interface RippleProps extends HTMLAttributes<HTMLElement> { export interface RippleProps extends HTMLAttributes<HTMLElement> {
rippleKey: number;
rippleX: number; rippleX: number;
rippleY: number; rippleY: number;
rippleS: number; rippleS: number;
endLifetime?: () => void;
lifetime: number; lifetime: number;
key?: number; endLifetime?: (value: number) => void;
} }

View File

@ -1,81 +0,0 @@
'use client';
import isEmpty from './utils/utils';
import { rippleAreaContext } from './ripple-area';
import { RippleProps, RipplesProps } from './ripple.types';
import RippleEffectBuild from './utils/ripple-effect-builder';
import React, {
ReactElement,
useContext,
useEffect,
useRef,
useState,
useTransition,
} from 'react';
const Ripples = (props: RipplesProps) => {
const [ripples, setRipples] = useState({});
const firstRender = useRef<boolean>(true);
const [pending, startTransition] = useTransition();
const endLifetime = (child: ReactElement) => {
if (child.props.endLifetime) {
child.props.endLifetime();
}
setRipples(state => {
const children = { ...state };
delete children[child.key];
return children;
});
};
useEffect(() => {
if (props.children.length > 0 && !pending) {
startTransition(() => {
if (firstRender.current || isEmpty(ripples)) {
setRipples(RippleEffectBuild(props.children, endLifetime));
firstRender.current = false;
} else {
setRipples(
RippleEffectBuild(props.children, endLifetime, ripples),
);
}
});
}
}, [props.children]);
return <>{Object.values(ripples)}</>;
};
const Ripple = ({
rippleX,
rippleY,
rippleS,
endLifetime,
lifetime,
}: RippleProps) => {
const clicked = useContext<boolean>(rippleAreaContext);
const [classes, setClasses] = useState<string>('m3 ripple visible');
useEffect(() => {
if (endLifetime !== null && !clicked) {
setClasses('m3 ripple');
setTimeout(endLifetime, lifetime);
}
}, [clicked, endLifetime]);
return (
<span
className={classes}
style={{
left: -(rippleS / 2) + rippleX,
top: -(rippleS / 2) + rippleY,
width: rippleS,
aspectRatio: 1,
}}
/>
);
};
export { Ripples, Ripple };

View File

@ -1,15 +0,0 @@
import { cloneElement, ReactElement } from 'react';
export default function ArrayConvertToObj(
obj: object,
nextChildren: ReactElement[],
callback: (child: ReactElement) => void,
): void {
Object.values(nextChildren).forEach(
(child: ReactElement) =>
(obj[child.key] = cloneElement(child, {
...child.props,
endLifetime: callback.bind(null, child),
})),
);
}

View File

@ -1,34 +0,0 @@
import isEmpty from './utils';
import ArrayConvertToObj from './array-convert-to-obj';
import { cloneElement, ReactElement } from 'react';
export default function RippleEffectBuild(
nextRipples: ReactElement[],
callback: (child: ReactElement) => void,
prevRipples?: object | null,
) {
const empty: boolean = isEmpty(prevRipples);
const preparedRipples: object = empty ? {} : prevRipples;
switch (empty) {
case true:
ArrayConvertToObj(preparedRipples, nextRipples, callback);
break;
case false:
// eslint-disable-next-line no-case-declarations
const next: object = {};
ArrayConvertToObj(next, nextRipples, callback);
for (const rippleKey of Object.keys(next)) {
if (preparedRipples[rippleKey] == undefined) {
preparedRipples[rippleKey] = cloneElement(next[rippleKey], {
...next[rippleKey].props,
endLifetime: callback.bind(null, next[rippleKey]),
});
}
}
break;
}
return preparedRipples;
}

View File

@ -1,6 +1,31 @@
import { InteractionEventsType } from '../hooks/useRippleEffect';
import { baseDOMRect } from '../ripple.types';
export default function isEmpty(obj: object): boolean { export default function isEmpty(obj: object): boolean {
for (const _i in obj) { for (const _i in obj) {
return false; return false;
} }
return true; return true;
} }
export function maxSize(size: number, cordPos: number): number {
return Math.max(Math.abs(size - cordPos), cordPos);
}
export function rippleSizeCalculator(
event: InteractionEventsType,
rippleDomain: DOMRect | baseDOMRect,
centralRipple: boolean,
): Array<number> {
const rippleX: number = !centralRipple
? event.clientX - rippleDomain.left
: rippleDomain.width / 2,
rippleY: number = !centralRipple
? event.clientY - rippleDomain.top
: rippleDomain.height / 2,
rippleSizeX: number = maxSize(rippleDomain.width, rippleX) * 2,
rippleSizeY: number = maxSize(rippleDomain.height, rippleY) * 2,
rippleS: number = (rippleSizeX ** 2 + rippleSizeY ** 2) ** 0.5;
return [rippleX, rippleY, rippleS];
}