Files
xrpl-dev-portal/shared/patterns/HeaderHeroPrimaryMedia/HeaderHeroPrimaryMedia.tsx
gabriel-ortiz 6a5f9e0db3 Add StandardCardGroupSection pattern component and related styles
- Introduced the StandardCardGroupSection component, which displays a headline, description, and a responsive grid of StandardCard components.
- Implemented SCSS styles for the StandardCardGroupSection, ensuring consistent spacing and dark mode support.
- Added utility functions for key generation and environment checks to enhance component functionality.
- Created a README file detailing usage, props, and best practices for the StandardCardGroupSection.
- Included new StandardCard component with customizable variants and button handling.
2026-06-08 16:53:22 -07:00

227 lines
6.5 KiB
TypeScript

import React, { forwardRef, memo, useEffect } from "react";
import clsx from "clsx";
import { PageGrid } from "shared/components/PageGrid/page-grid";
import { Button, ButtonProps } from "shared/components/Button/Button";
import {
isEmpty,
DesignContrainedButtonProps,
isEnvironment,
} from "shared/utils";
/**
* Base props that all media elements must have to ensure proper styling.
* These props are automatically applied to maintain the 9:16 aspect ratio
* and object-fit: cover behavior.
*/
type MediaStyleProps = {
className?: string;
style?: React.CSSProperties;
};
/**
* Image media type - extends native img element props
*/
type ImageMediaProps = {
type: "image";
} & Omit<
React.ComponentPropsWithoutRef<"img">,
keyof MediaStyleProps | "src" | "alt"
> & {
src: string; // Required for image media
alt: string; // Required for image media
};
/**
* Video media type - extends native video element props
*/
type VideoMediaProps = {
type: "video";
} & Omit<
React.ComponentPropsWithoutRef<"video">,
keyof MediaStyleProps | "src"
> & {
src: string; // Required for video media
alt?: string; // Optional for video, but recommended for accessibility
};
/**
* Custom element media type - allows passing any React element
* The element will be wrapped in a container with the required aspect ratio
*/
type CustomMediaProps = {
type: "custom";
element: React.ReactElement;
};
/**
* Discriminated union of all supported media types.
* Each type allows extending native React element props while ensuring
* the media container maintains the 9:16 aspect ratio and object-fit: cover.
*/
export type HeaderHeroMedia =
| ImageMediaProps
| VideoMediaProps
| CustomMediaProps;
export interface HeaderHeroPrimaryMediaProps extends React.ComponentPropsWithoutRef<"header"> {
/** Hero title text (display-md typography) */
headline: React.ReactNode;
/** Hero subtitle text (subhead-sm-l typography) */
subtitle: React.ReactNode;
callsToAction: [DesignContrainedButtonProps, DesignContrainedButtonProps?];
/** Media element - supports image, video, or custom React element */
media: HeaderHeroMedia;
}
/**
* Renders the appropriate media element based on the media type.
* All media is wrapped in a container with 9:16 aspect ratio and object-fit: cover.
*/
const MediaRenderer: React.FC<{ media: HeaderHeroMedia }> = memo(
({ media }) => {
const mediaContainerClassName =
"bds-header-hero-primary-media__media-container";
const mediaElementClassName =
"bds-header-hero-primary-media__media-element";
switch (media.type) {
case "image": {
const { type, ...imgProps } = media;
return (
<div className={mediaContainerClassName}>
<img {...imgProps} className={mediaElementClassName} />
</div>
);
}
case "video": {
const { type, alt, ...videoProps } = media;
return (
<div className={mediaContainerClassName}>
<video
{...videoProps}
className={mediaElementClassName}
aria-label={alt}
/>
</div>
);
}
case "custom": {
const { element } = media;
return (
<div className={mediaContainerClassName}>
<div className={mediaElementClassName}>{element}</div>
</div>
);
}
default: {
return null;
}
}
},
);
const HeaderHeroPrimaryMedia = forwardRef<
HTMLElement,
HeaderHeroPrimaryMediaProps
>((props, ref) => {
const { headline, subtitle, callsToAction, media, className, ...restProps } =
props;
const [primaryCta, secondaryCta] = callsToAction;
// Headline is critical - exit early if missing
if (!headline) {
if (isEnvironment("development")) {
console.error("Headline is required for HeaderHeroPrimaryMedia");
}
return null;
}
// Validate other props and log warnings for missing optional/required fields
// Note: These props log warnings but don't prevent rendering
useEffect(() => {
if (!isEnvironment(["development", "test"])) {
return;
}
const propsToValidate = {
subtitle,
callsToAction,
media,
};
Object.entries(propsToValidate).forEach(([key, value]) => {
if (isEmpty(value)) {
console.warn(`${key} is required for HeaderHeroPrimaryMedia`);
}
});
}, [subtitle, callsToAction, media]);
return (
<header
className={clsx("bds-header-hero-primary-media", className)}
ref={ref}
{...restProps}
>
<PageGrid>
<PageGrid.Row>
<PageGrid.Col
span={{ base: 12, md: 6, lg: 5 }}
className="bds-header-hero-primary-media__headline-container"
>
<h1 className="bds-header-hero-primary-media__headline display-md">
<span>{headline}</span>
</h1>
</PageGrid.Col>
<PageGrid.Col offset={{ base: 0, lg: 1 }} span={{ base: 12, lg: 5 }}>
<div className="bds-header-hero-primary-media__cta-container">
{!isEmpty(subtitle) && (
<div className="bds-header-hero-primary-media__subtitle body-l">
{subtitle}
</div>
)}
{(!isEmpty(primaryCta) || !isEmpty(secondaryCta)) && (
<div className="bds-header-hero-primary-media__cta-buttons">
{!isEmpty(primaryCta) && (
<Button
{...primaryCta!}
variant="primary"
color="green"
showIcon={true}
/>
)}
{!isEmpty(secondaryCta) && (
<Button
{...secondaryCta!}
className={clsx(
"bds-header-hero-primary-media__cta-button-tertiary",
secondaryCta?.className,
)}
variant="tertiary"
color="green"
showIcon={true}
/>
)}
</div>
)}
</div>
</PageGrid.Col>
</PageGrid.Row>
{/* Media */}
{!isEmpty(media) && (
<PageGrid.Row>
<PageGrid.Col span={12}>
<MediaRenderer media={media} />
</PageGrid.Col>
</PageGrid.Row>
)}
</PageGrid>
</header>
);
});
export default HeaderHeroPrimaryMedia;