Initial commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
.feature-card-three-title {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.feature-card-three-description {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Mobile touch support - duplicate hover states for is-active class */
|
||||
.feature-card-three-item.is-active .feature-card-three-item-box > div {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.feature-card-three-item.is-active .feature-card-three-content-wrapper {
|
||||
transform: translateY(var(--hover-translate-y));
|
||||
}
|
||||
|
||||
.feature-card-three-item.is-active .feature-card-three-title {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.feature-card-three-item.is-active .feature-card-three-description-wrapper {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.feature-card-three-item.is-active .feature-card-three-reveal-bg {
|
||||
--tw-translate-y: 0px !important;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important;
|
||||
left: calc(var(--vw-1_5) * 0.75);
|
||||
bottom: calc(var(--vw-1_5) * 0.75);
|
||||
right: calc(var(--vw-1_5) * 0.75);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import "./FeatureCardThree.css";
|
||||
import { useRef, useCallback, useState } from "react";
|
||||
import CardStack from "@/components/cardStack/CardStack";
|
||||
import FeatureCardThreeItem from "./FeatureCardThreeItem";
|
||||
import { useDynamicDimensions } from "./useDynamicDimensions";
|
||||
import { useClickOutside } from "@/hooks/useClickOutside";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ButtonConfig, GridVariant, CardAnimationType, TitleSegment } from "@/components/cardStack/types";
|
||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||
|
||||
type FeatureCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
imageSrc: string;
|
||||
imageAlt?: string;
|
||||
};
|
||||
|
||||
interface FeatureCardThreeProps {
|
||||
features: FeatureCard[];
|
||||
carouselMode?: "auto" | "buttons";
|
||||
gridVariant: GridVariant;
|
||||
uniformGridCustomHeightClasses?: string;
|
||||
animationType: CardAnimationType;
|
||||
title: string;
|
||||
titleSegments?: TitleSegment[];
|
||||
description: string;
|
||||
tag?: string;
|
||||
tagIcon?: LucideIcon;
|
||||
buttons?: ButtonConfig[];
|
||||
textboxLayout: TextboxLayout;
|
||||
useInvertedBackground: InvertedBackground;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
cardClassName?: string;
|
||||
textBoxTitleClassName?: string;
|
||||
textBoxTitleImageWrapperClassName?: string;
|
||||
textBoxTitleImageClassName?: string;
|
||||
textBoxDescriptionClassName?: string;
|
||||
cardTitleClassName?: string;
|
||||
cardDescriptionClassName?: string;
|
||||
gridClassName?: string;
|
||||
carouselClassName?: string;
|
||||
controlsClassName?: string;
|
||||
textBoxClassName?: string;
|
||||
textBoxTagClassName?: string;
|
||||
textBoxButtonContainerClassName?: string;
|
||||
textBoxButtonClassName?: string;
|
||||
textBoxButtonTextClassName?: string;
|
||||
itemContentClassName?: string;
|
||||
}
|
||||
|
||||
const FeatureCardThree = ({
|
||||
features,
|
||||
carouselMode = "buttons",
|
||||
gridVariant,
|
||||
uniformGridCustomHeightClasses,
|
||||
animationType,
|
||||
title,
|
||||
titleSegments,
|
||||
description,
|
||||
tag,
|
||||
tagIcon,
|
||||
buttons,
|
||||
textboxLayout,
|
||||
useInvertedBackground,
|
||||
ariaLabel = "Feature section",
|
||||
className = "",
|
||||
containerClassName = "",
|
||||
cardClassName = "",
|
||||
textBoxTitleClassName = "",
|
||||
textBoxTitleImageWrapperClassName = "",
|
||||
textBoxTitleImageClassName = "",
|
||||
textBoxDescriptionClassName = "",
|
||||
cardTitleClassName = "",
|
||||
cardDescriptionClassName = "",
|
||||
gridClassName = "",
|
||||
carouselClassName = "",
|
||||
controlsClassName = "",
|
||||
textBoxClassName = "",
|
||||
textBoxTagClassName = "",
|
||||
textBoxButtonContainerClassName = "",
|
||||
textBoxButtonClassName = "",
|
||||
textBoxButtonTextClassName = "",
|
||||
itemContentClassName = "",
|
||||
}: FeatureCardThreeProps) => {
|
||||
const featureCardThreeRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
// Override heightClasses for staggered variants to use smaller heights
|
||||
const heightClassesOverride =
|
||||
gridVariant === "four-items-2x2-staggered-grid" || gridVariant === "four-items-2x2-staggered-grid-inverted"
|
||||
? "h-110 2xl:h-120"
|
||||
: uniformGridCustomHeightClasses;
|
||||
|
||||
const setRef = useCallback(
|
||||
(index: number) => (el: HTMLDivElement | null) => {
|
||||
if (featureCardThreeRefs.current) {
|
||||
featureCardThreeRefs.current[index] = el;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Check if device supports hover (desktop) or not (mobile/touch)
|
||||
const isTouchDevice = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||
|
||||
// Handle click outside to deactivate on mobile
|
||||
useClickOutside(
|
||||
containerRef,
|
||||
() => setActiveIndex(null),
|
||||
activeIndex !== null && isTouchDevice
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback((index: number) => {
|
||||
if (typeof window !== "undefined" && !window.matchMedia("(hover: none)").matches) return;
|
||||
setActiveIndex((prev) => (prev === index ? null : index));
|
||||
}, []);
|
||||
|
||||
useDynamicDimensions([featureCardThreeRefs], {
|
||||
titleSelector: ".feature-card-three-title-row .feature-card-three-title",
|
||||
descriptionSelector: ".feature-card-three-description-wrapper .feature-card-three-description",
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<CardStack
|
||||
mode={carouselMode}
|
||||
gridVariant={gridVariant}
|
||||
uniformGridCustomHeightClasses={heightClassesOverride}
|
||||
animationType={animationType}
|
||||
|
||||
title={title}
|
||||
titleSegments={titleSegments}
|
||||
description={description}
|
||||
tag={tag}
|
||||
tagIcon={tagIcon}
|
||||
buttons={buttons}
|
||||
textboxLayout={textboxLayout}
|
||||
useInvertedBackground={useInvertedBackground}
|
||||
className={className}
|
||||
containerClassName={containerClassName}
|
||||
gridClassName={gridClassName}
|
||||
carouselClassName={carouselClassName}
|
||||
controlsClassName={controlsClassName}
|
||||
textBoxClassName={textBoxClassName}
|
||||
titleClassName={textBoxTitleClassName}
|
||||
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
|
||||
titleImageClassName={textBoxTitleImageClassName}
|
||||
descriptionClassName={textBoxDescriptionClassName}
|
||||
tagClassName={textBoxTagClassName}
|
||||
buttonContainerClassName={textBoxButtonContainerClassName}
|
||||
buttonClassName={textBoxButtonClassName}
|
||||
buttonTextClassName={textBoxButtonTextClassName}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<FeatureCardThreeItem
|
||||
key={`${feature.id}-${index}`}
|
||||
ref={setRef(index)}
|
||||
item={feature}
|
||||
isActive={activeIndex === index}
|
||||
onItemClick={() => handleItemClick(index)}
|
||||
className={cardClassName}
|
||||
itemContentClassName={itemContentClassName}
|
||||
itemTitleClassName={cardTitleClassName}
|
||||
itemDescriptionClassName={cardDescriptionClassName}
|
||||
/>
|
||||
))}
|
||||
</CardStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FeatureCardThree.displayName = "FeatureCardThree";
|
||||
|
||||
export default FeatureCardThree;
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, memo } from "react";
|
||||
import Image from "next/image";
|
||||
import { Info } from "lucide-react";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
interface FeatureCardThreeItemData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
imageSrc: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
interface FeatureCardThreeItemProps {
|
||||
item: FeatureCardThreeItemData;
|
||||
isActive?: boolean;
|
||||
onItemClick?: () => void;
|
||||
className?: string;
|
||||
itemContentClassName?: string;
|
||||
itemTitleClassName?: string;
|
||||
itemDescriptionClassName?: string;
|
||||
}
|
||||
|
||||
const MASK_GRADIENT = "linear-gradient(to bottom, transparent, black 60%)";
|
||||
|
||||
const FeatureCardThreeItem = memo(
|
||||
forwardRef<HTMLDivElement, FeatureCardThreeItemProps>(
|
||||
(
|
||||
{
|
||||
item,
|
||||
isActive = false,
|
||||
onItemClick,
|
||||
className = "",
|
||||
itemContentClassName = "",
|
||||
itemTitleClassName = "",
|
||||
itemDescriptionClassName = "",
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cls(
|
||||
"feature-card-three-item relative overflow-hidden h-full rounded-theme-capped group",
|
||||
isActive && "is-active",
|
||||
className
|
||||
)}
|
||||
role="article"
|
||||
aria-label={`${item.title} - Feature ${item.id}`}
|
||||
tabIndex={0}
|
||||
onClick={onItemClick}
|
||||
>
|
||||
<div
|
||||
className="feature-card-three-item-box absolute top-6 left-6 z-10 flex items-center justify-center [perspective:1000px] [transform-style:preserve-3d]"
|
||||
>
|
||||
<div
|
||||
className="relative h-8 aspect-square rounded-theme bg-background transition-transform duration-400 ease-[cubic-bezier(0.4,0,0.2,1)] [transform-style:preserve-3d] group-hover:[transform:rotateY(180deg)]"
|
||||
>
|
||||
<div
|
||||
className="feature-card-three-item-box-front absolute w-full h-full rounded-theme bg-background flex items-center justify-center [backface-visibility:hidden]"
|
||||
>
|
||||
<p
|
||||
className="feature-card-three-description text-foreground truncate"
|
||||
>
|
||||
{item.id}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="feature-card-three-item-box-back absolute w-full h-full rounded-theme bg-background flex items-center justify-center [backface-visibility:hidden] [transform:rotateY(180deg)]"
|
||||
>
|
||||
<Info
|
||||
className="w-1/2 h-1/2 text-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Image
|
||||
src={item.imageSrc}
|
||||
alt={item.imageAlt || item.title}
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="relative z-1 object-cover rounded-theme-capped h-full w-full "
|
||||
unoptimized={item.imageSrc.startsWith('http') || item.imageSrc.startsWith('//')}
|
||||
aria-hidden={item.imageAlt === ""}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute z-10 bottom-0 left-0 right-0 h-30 backdrop-blur-xl opacity-100"
|
||||
style={{ maskImage: MASK_GRADIENT }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="feature-card-three-content-wrapper absolute z-20 transition-all duration-400 ease-out flex flex-col gap-2 group-hover:[transform:translateY(var(--hover-translate-y))]"
|
||||
style={{
|
||||
top: "var(--content-top-position)",
|
||||
left: "calc((var(--vw-1_5) * 1.5))",
|
||||
width: "calc(100% - (var(--vw-1_5) * 3))",
|
||||
}}
|
||||
>
|
||||
<div className="feature-card-three-title-row">
|
||||
<h2
|
||||
className={cls(
|
||||
"feature-card-three-title font-semibold leading-[110%] transition-colors text-background group-hover:text-foreground",
|
||||
itemTitleClassName
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="feature-card-three-description-wrapper transition-all duration-400 ease-out opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<p
|
||||
className={cls("feature-card-three-description leading-[120%] w-full text-foreground", itemDescriptionClassName)}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cls(
|
||||
"feature-card-three-reveal-bg !absolute left-0 bottom-0 card z-10 rounded-theme-capped transition-all duration-400 ease-out translate-y-full right-0 group-hover:translate-y-0 group-hover:left-[calc(var(--vw-1_5)*0.75)] group-hover:bottom-[calc(var(--vw-1_5)*0.75)] group-hover:right-[calc(var(--vw-1_5)*0.75)]",
|
||||
itemContentClassName
|
||||
)}
|
||||
style={{
|
||||
height: "var(--reveal-bg-height)",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
FeatureCardThreeItem.displayName = "FeatureCardThreeItem";
|
||||
|
||||
export default FeatureCardThreeItem;
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useCallback, useMemo } from 'react'
|
||||
|
||||
let cachedVw15: number | null = null
|
||||
let lastWindowWidth = 0
|
||||
|
||||
const getVw15InPixels = (): number => {
|
||||
const currentWidth = window.innerWidth
|
||||
if (cachedVw15 !== null && lastWindowWidth === currentWidth) {
|
||||
return cachedVw15
|
||||
}
|
||||
|
||||
const temp = document.createElement('div')
|
||||
temp.style.position = 'absolute'
|
||||
temp.style.width = 'var(--vw-1_5)'
|
||||
document.body.appendChild(temp)
|
||||
const width = temp.getBoundingClientRect().width
|
||||
document.body.removeChild(temp)
|
||||
|
||||
cachedVw15 = width || 0
|
||||
lastWindowWidth = currentWidth
|
||||
return cachedVw15
|
||||
}
|
||||
|
||||
|
||||
const debounce = <T extends (...args: unknown[]) => void>(func: T, wait: number): ((...args: Parameters<T>) => void) & { cancel: () => void } => {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
|
||||
const debouncedFunction = function executedFunction(...args: Parameters<T>) {
|
||||
const later = () => {
|
||||
timeout = null
|
||||
func(...args)
|
||||
}
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
|
||||
debouncedFunction.cancel = () => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
}
|
||||
|
||||
return debouncedFunction
|
||||
}
|
||||
|
||||
interface DynamicDimensionsOptions {
|
||||
titleSelector?: string
|
||||
descriptionSelector?: string
|
||||
containerSelector?: string | null
|
||||
}
|
||||
|
||||
type RefArray = React.RefObject<(HTMLDivElement | null)[]> | React.RefObject<(HTMLDivElement | null)[]>[]
|
||||
|
||||
export const useDynamicDimensions = (refs: RefArray, options: DynamicDimensionsOptions = {}) => {
|
||||
const {
|
||||
titleSelector = '.feature-card-three-title',
|
||||
descriptionSelector = '.feature-card-three-description',
|
||||
containerSelector = null
|
||||
} = options
|
||||
|
||||
const calculateDimensions = useCallback(() => {
|
||||
const processRef = (ref: HTMLElement | null) => {
|
||||
if (!ref) return
|
||||
|
||||
const container = containerSelector ? ref.querySelector(containerSelector) as HTMLElement : ref
|
||||
if (!container) return
|
||||
|
||||
const titleElement = container.querySelector(titleSelector) as HTMLElement
|
||||
const descriptionElement = container.querySelector(descriptionSelector) as HTMLElement
|
||||
|
||||
if (titleElement && descriptionElement) {
|
||||
const titleHeight = titleElement.offsetHeight
|
||||
const descriptionHeight = descriptionElement.offsetHeight
|
||||
|
||||
const contentTop = `calc(100% - ${titleHeight}px - calc(var(--vw-1_5) * 1.5))`
|
||||
|
||||
const vw15 = getVw15InPixels()
|
||||
|
||||
const contentWrapperHeight = titleHeight + descriptionHeight
|
||||
|
||||
const revealBgHeight = contentWrapperHeight + (vw15 * 2)
|
||||
|
||||
const moveUp = descriptionHeight + (vw15 * 0.55)
|
||||
|
||||
ref.style.setProperty('--reveal-bg-height', `${revealBgHeight}px`)
|
||||
ref.style.setProperty('--content-top-position', contentTop)
|
||||
ref.style.setProperty('--hover-translate-y', `-${moveUp}px`)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(refs)) {
|
||||
refs.forEach((refArray) => {
|
||||
if (refArray?.current && Array.isArray(refArray.current)) {
|
||||
refArray.current.forEach(processRef)
|
||||
}
|
||||
})
|
||||
} else if ('current' in refs && refs.current) {
|
||||
if (Array.isArray(refs.current)) {
|
||||
refs.current.forEach(processRef)
|
||||
} else {
|
||||
processRef(refs.current)
|
||||
}
|
||||
}
|
||||
}, [titleSelector, descriptionSelector, containerSelector, refs])
|
||||
|
||||
const debouncedCalculate = useMemo(
|
||||
() => debounce(calculateDimensions, 250),
|
||||
[calculateDimensions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
calculateDimensions()
|
||||
window.addEventListener('resize', debouncedCalculate)
|
||||
return () => {
|
||||
window.removeEventListener('resize', debouncedCalculate)
|
||||
debouncedCalculate.cancel()
|
||||
}
|
||||
}, [calculateDimensions, debouncedCalculate])
|
||||
}
|
||||
Reference in New Issue
Block a user