Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 55 additions & 84 deletions src/custom/FlipCard/FlipCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,130 +4,101 @@ import { BUTTON_MODAL_DARK, WHITE } from '../../theme/colors/colors';

export type FlipCardProps = {
duration?: number;
onClick?: () => void;
onShow?: () => void;
children: [React.ReactNode, React.ReactNode];
disableFlip?: boolean;
padding?: string;
frontElement: React.ReactNode;
backElement: React.ReactNode;
flipAction?: 'hover' | 'click';
};

/**
* Helper function to get the front or back child component from the children array
* @param children Array containing exactly two child components
* @param key Index to retrieve (0 for front, 1 for back)
* @throws Error if children is undefined or doesn't contain exactly two components
* @returns The selected child component
*/
function GetChild(children: [React.ReactNode, React.ReactNode], key: number) {
if (!children) throw Error('FlipCard requires exactly two child components');
if (children.length != 2) throw Error('FlipCard requires exactly two child components');

return children[key];
}

const Card = styled('div')(({ theme }) => ({
const Card = styled('div')({
height: '100%',
backgroundColor: 'transparent',
perspective: theme.spacing(125)
}));
width: '100%',
perspective: '1000px',
});

const InnerCard = styled('div')(({ theme }) => ({
padding: theme.spacing(2),
borderRadius: theme.spacing(1),
const InnerCard = styled('div', {
// Prevent 'flipped' prop from leaking to the DOM element
shouldForwardProp: (prop) => prop !== 'flipped',
})<{ flipped: boolean }>(({ flipped }) => ({
position: 'relative',
height: '100%',
width: '100%',
transformStyle: 'preserve-3d',
boxShadow: '0 4px 8px 0 rgba(0,0,0,0.2)',
backgroundColor: theme.palette.mode === 'dark' ? BUTTON_MODAL_DARK : WHITE,
cursor: 'pointer',
transformOrigin: '50% 50% 10%'
transform: flipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
}));

const FrontContent = styled('div')({
backfaceVisibility: 'hidden'
position: 'absolute',
height: '100%',
width: '100%',
backfaceVisibility: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});

const BackContent = styled('div')({
position: 'absolute',
height: '100%',
width: '100%',
backfaceVisibility: 'hidden',
transform: 'scale(-1, 1)',
wordBreak: 'break-word'
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: 'rotateY(180deg)',
});

/**
* A card component that provides a flipping animation between two content faces
*
* @component
* @param props.duration - Animation duration in milliseconds (default: 500)
* @param props.onClick - Callback function triggered on card click
* @param props.onShow - Additional callback function triggered when card shows new face
* @param props.children - Array of exactly two child components (front and back)
* @param props.flipAction - The action that triggers the flip animation ('hover' or 'click') (default: 'click')
* @param props.frontElement - React node to be displayed on the front face of the card
* @param props.backElement - React node to be displayed on the back face of the card
* @param props.disableFlip - When true, prevents the card from flipping (default: false)
*
* @example
* ```tsx
* <FlipCard>
* <div>Front Content</div>
* <div>Back Content</div>
* </FlipCard>
* <FlipCard
* frontElement={<div>Front Content</div>}
* backElement={<div>Back Content</div>}
* flipAction="hover"
* />
* ```
*/
export function FlipCard({
duration = 500,
onClick,
onShow,
children,
frontElement,
backElement,
disableFlip = false,
padding
flipAction = 'click'
}: FlipCardProps) {
const [flipped, setFlipped] = React.useState(false);
const [activeBack, setActiveBack] = React.useState(false);

const timeout = React.useRef<null | NodeJS.Timeout>(null);

const Front = GetChild(children, 0);
const Back = GetChild(children, 1);

React.useEffect(() => {
// This function makes sure that the inner content of the card disappears roughly
// after 30 deg rotation has already occured. It will ensure that the user doesn't gets
// a "blank" card while the card is rotating
//
// This guarantee can be offered because of two main reasons:
// 1. In sufficiently modern browsers JS and CSS are handled in different threads
// hence ones execution doesn't blocks another.
// 2. setTimeout will put its callback at the end of current context's end hence ensuring
// this callback doesn't gets blocked by another JS process.

const handleFlip = () => {
if (!disableFlip) setFlipped((prev) => !prev);
};

if (timeout.current) clearTimeout(timeout.current);
// Determine triggers
const triggerProps = flipAction === 'click'
? { onClick: handleFlip }
: { onMouseEnter: () => setFlipped(true), onMouseLeave: () => setFlipped(false) };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The triggerProps logic has two issues:

  1. Accessibility: When flipAction is 'click', the component acts as a button but lacks a role, tabIndex, and keyboard event handlers, making it inaccessible to keyboard and screen reader users.
  2. Correctness: The hover triggers do not check the disableFlip prop, allowing the card to flip even when flipping is disabled.
const triggerProps = flipAction === 'click' 
    ? { 
        onClick: handleFlip,
        onKeyDown: (e: React.KeyboardEvent) => {
          if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault();
            handleFlip();
          }
        },
        role: 'button',
        tabIndex: 0,
        'aria-pressed': flipped
      } 
    : { 
        onMouseEnter: () => !disableFlip && setFlipped(true), 
        onMouseLeave: () => !disableFlip && setFlipped(false) 
      };


timeout.current = setTimeout(() => {
setActiveBack(flipped);
}, duration / 6);
}, [flipped, duration]);

return (
<Card
onClick={() => {
if (disableFlip) return;
setFlipped((flipped) => !flipped);
if (onClick) {
onClick();
}
if (onShow) {
onShow();
}
}}
>
<Card {...triggerProps}>
<InnerCard
flipped={flipped}
style={{
transform: flipped ? 'scale(-1,1)' : undefined,
transition: `transform ${duration}ms`,
padding: padding
transition: `transform ${duration}ms`
}}
>
{!activeBack ? (
<FrontContent>{React.isValidElement(Front) ? Front : null}</FrontContent>
) : (
<BackContent>{React.isValidElement(Back) ? Back : null}</BackContent>
)}
<FrontContent>{React.isValidElement(frontElement) ? frontElement : <div>Invalid Front Content</div>}</FrontContent>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

React.isValidElement returns false for valid React nodes such as strings or numbers. This causes the component to incorrectly display the fallback message when plain text is passed to frontElement. Additionally, hardcoding UI strings like "Invalid Front Content" violates internationalization best practices.

Suggested change
<FrontContent>{React.isValidElement(frontElement) ? frontElement : <div>Invalid Front Content</div>}</FrontContent>
<FrontContent>{frontElement}</FrontContent>
References
  1. Avoid hardcoding UI strings (such as button labels) in shared components. Expose these strings as configurable props to support internationalization (i18n) and localization.


<BackContent>{React.isValidElement(backElement) ? backElement : <div>Invalid Back Content</div>}</BackContent>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the front face, using React.isValidElement prevents valid non-element nodes (like strings) from being rendered correctly on the back face. It is recommended to render the prop directly.

Suggested change
<BackContent>{React.isValidElement(backElement) ? backElement : <div>Invalid Back Content</div>}</BackContent>
<BackContent>{backElement}</BackContent>
References
  1. Avoid hardcoding UI strings (such as button labels) in shared components. Expose these strings as configurable props to support internationalization (i18n) and localization.


</InnerCard>
</Card>
);
Expand Down
Loading