Compare commits

..

12 Commits

Author SHA1 Message Date
akcodez
33c6315510 Refactor CarouselCardList and CarouselFeatured to streamline button rendering logic using a map function for navigation buttons, enhancing code maintainability and consistency across components. 2026-02-03 10:40:08 -08:00
akcodez
af0b8cd40a Refactor CarouselFeatured to use ButtonGroup for button management, simplifying button rendering logic and improving code maintainability. 2026-02-03 10:38:45 -08:00
akcodez
95c4ffaa1b merge buttongroup in 2026-02-03 10:29:25 -08:00
Calvin
08c5572f16 Merge pull request #3475 from XRPLF/go/feat/FeaturedVideoSection
Add FeaturedVideoHero pattern component with showcase and styles
2026-02-03 10:25:12 -08:00
Calvin Jhunjhuwala
b49bc02dd2 fixing button and button groups for feature 2 column 2026-02-02 17:03:04 -08:00
Calvin Jhunjhuwala
65a61c5e47 fixing spacing and alignments 2026-02-02 16:20:50 -08:00
Calvin Jhunjhuwala
237ddc3c74 cleaning up styles, aligning text to bottom for section 2026-02-02 15:57:20 -08:00
Calvin
dd6cfd34fe Merge pull request #3474 from XRPLF/pattern/button-group
Add ButtonGroup pattern component
2026-02-02 14:30:26 -08:00
Calvin Jhunjhuwala
7b601da3a0 updating to add more warnings/dev feedback 2026-02-02 13:20:12 -08:00
gabriel-ortiz
6021b458e6 Add FeaturedVideoHero pattern component with showcase and styles
- Introduced the FeaturedVideoHero component featuring a responsive layout with a headline, optional subtitle, call-to-action buttons, and a video element.
- Added associated SCSS styles for the component to ensure proper spacing and theming.
- Created a showcase page to demonstrate the usage of the FeaturedVideoHero pattern.
- Updated types to include design constraints for buttons and video elements.
- Implemented validation for required props to enhance development experience.
2026-01-30 13:47:38 -08:00
Calvin Jhunjhuwala
e5f3bf75e3 minor add for maxnumber 2026-01-30 10:30:53 -08:00
Calvin Jhunjhuwala
7dd32d63da working button group, updated examples 2026-01-29 16:50:32 -08:00
26 changed files with 1586 additions and 657 deletions

View File

@@ -40,8 +40,10 @@ export default function CalloutMediaBannerShowcase() {
variant="green"
heading="The Compliant Ledger Protocol"
subheading="A decentralized public Layer 1 blockchain for creating, transferring, and exchanging digital assets with a focus on compliance."
primaryButton={{ label: "Get Started", onClick: () => handleClick('responsive-demo-primary') }}
tertiaryButton={{ label: "Learn More", onClick: () => handleClick('responsive-demo-tertiary') }}
buttons={[
{ label: "Get Started", onClick: () => handleClick('responsive-demo-primary') },
{ label: "Learn More", onClick: () => handleClick('responsive-demo-tertiary') }
]}
/>
{/* Responsive Behavior */}
@@ -122,7 +124,9 @@ export default function CalloutMediaBannerShowcase() {
variant="default"
heading="Build on XRPL"
subheading="Start building your next decentralized application on the XRP Ledger."
primaryButton={{ label: "Start Building", href: "#start" }}
buttons={[
{ label: "Start Building", href: "#start" }
]}
/>
</div>
@@ -143,8 +147,10 @@ export default function CalloutMediaBannerShowcase() {
variant="light-gray"
heading="Developer Resources"
subheading="Access comprehensive documentation, tutorials, and code samples."
primaryButton={{ label: "View Docs", href: "#docs" }}
tertiaryButton={{ label: "Browse Tutorials", href: "#tutorials" }}
buttons={[
{ label: "View Docs", href: "#docs" },
{ label: "Browse Tutorials", href: "#tutorials" }
]}
/>
</div>
@@ -165,7 +171,9 @@ export default function CalloutMediaBannerShowcase() {
variant="lilac"
heading="New Feature Release"
subheading="Discover the latest enhancements and capabilities added to the XRP Ledger."
primaryButton={{ label: "Learn More", href: "#features" }}
buttons={[
{ label: "Learn More", href: "#features" }
]}
/>
</div>
@@ -186,8 +194,10 @@ export default function CalloutMediaBannerShowcase() {
variant="green"
heading="The Compliant Ledger Protocol"
subheading="A decentralized public Layer 1 blockchain for creating, transferring, and exchanging digital assets with a focus on compliance."
primaryButton={{ label: "Get Started", href: "#get-started" }}
tertiaryButton={{ label: "Learn More", href: "#learn" }}
buttons={[
{ label: "Get Started", href: "#get-started" },
{ label: "Learn More", href: "#learn" }
]}
/>
</div>
@@ -208,8 +218,10 @@ export default function CalloutMediaBannerShowcase() {
variant="gray"
heading="Join the Community"
subheading="Connect with developers building on XRPL."
primaryButton={{ label: "Join Discord", href: "#discord" }}
tertiaryButton={{ label: "View Events", href: "#events" }}
buttons={[
{ label: "Join Discord", href: "#discord" },
{ label: "View Events", href: "#events" }
]}
/>
</div>
@@ -255,7 +267,9 @@ export default function CalloutMediaBannerShowcase() {
<CalloutMediaBanner
backgroundImage={sampleBackgroundImage}
subheading="A decentralized public Layer 1 blockchain for creating, transferring, and exchanging digital assets with a focus on compliance."
primaryButton={{ label: "Start Building", onClick: () => handleClick('image-white-primary') }}
buttons={[
{ label: "Start Building", onClick: () => handleClick('image-white-primary') }
]}
/>
</div>
@@ -277,8 +291,10 @@ export default function CalloutMediaBannerShowcase() {
textColor="black"
heading="Build the Future of Finance"
subheading="Create powerful decentralized applications with XRPL's fast, efficient, and sustainable blockchain technology."
primaryButton={{ label: "Start Building", onClick: () => handleClick('image-black-primary') }}
tertiaryButton={{ label: "Explore Features", onClick: () => handleClick('image-black-tertiary') }}
buttons={[
{ label: "Start Building", onClick: () => handleClick('image-black-primary') },
{ label: "Explore Features", onClick: () => handleClick('image-black-tertiary') }
]}
/>
</div>
@@ -321,8 +337,10 @@ export default function CalloutMediaBannerShowcase() {
variant="default"
heading="Complete Feature Set"
subheading="Access all the tools you need to build on XRPL."
primaryButton={{ label: "Get Started", href: "#start" }}
tertiaryButton={{ label: "Learn More", href: "#learn" }}
buttons={[
{ label: "Get Started", href: "#start" },
{ label: "Learn More", href: "#learn" }
]}
/>
</div>
@@ -343,7 +361,9 @@ export default function CalloutMediaBannerShowcase() {
variant="light-gray"
heading="Simple Call-to-Action"
subheading="Focus user attention on a single primary action."
primaryButton={{ label: "Take Action", href: "#action" }}
buttons={[
{ label: "Take Action", href: "#action" }
]}
/>
</div>
@@ -383,8 +403,10 @@ export default function CalloutMediaBannerShowcase() {
<CalloutMediaBanner
variant="green"
subheading="Important information or announcement without requiring user action."
primaryButton={{ label: "Take Action", href: "#action" }}
tertiaryButton={{ label: "Learn More", href: "#learn" }}
buttons={[
{ label: "Take Action", href: "#action" },
{ label: "Learn More", href: "#learn" }
]}
/>
</div>
@@ -564,20 +586,12 @@ export default function CalloutMediaBannerShowcase() {
<div style={{ flex: '1 1 0', minWidth: 0 }}>Subheading/description text</div>
</div>
{/* primaryButton */}
{/* buttons */}
<div className="d-flex flex-row py-3" style={{ gap: '1rem', borderBottom: '1px solid var(--bs-border-color, #dee2e6)' }}>
<div style={{ width: '140px', flexShrink: 0 }}><code>primaryButton</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}><code>{`{ label, href?, onClick? }`}</code></div>
<div style={{ width: '140px', flexShrink: 0 }}><code>buttons</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}><code>{`Array<{ label, href?, onClick?, forceColor? }>`}</code></div>
<div style={{ width: '120px', flexShrink: 0 }}><code>undefined</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}>Primary button configuration</div>
</div>
{/* tertiaryButton */}
<div className="d-flex flex-row py-3" style={{ gap: '1rem', borderBottom: '1px solid var(--bs-border-color, #dee2e6)' }}>
<div style={{ width: '140px', flexShrink: 0 }}><code>tertiaryButton</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}><code>{`{ label, href?, onClick? }`}</code></div>
<div style={{ width: '120px', flexShrink: 0 }}><code>undefined</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}>Tertiary button configuration</div>
<div style={{ flex: '1 1 0', minWidth: 0 }}>Button configurations (1-2 buttons supported)</div>
</div>
{/* className */}

View File

@@ -34,12 +34,12 @@ export default function FeatureTwoColumnShowcase() {
<PageGridCol span={12}>
<h2 className="h4 mb-6">Button Behavior</h2>
<p className="mb-4">
The component automatically adjusts button rendering based on the number of links provided:
The component uses the ButtonGroup pattern which automatically adjusts button rendering based on the number of links provided:
</p>
<ul className="mb-6">
<li><strong>1 link:</strong> Secondary button</li>
<li><strong>2 links:</strong> Primary + Tertiary buttons</li>
<li><strong>3-5 links:</strong> Primary + Tertiary (row), Secondary, then remaining Tertiary links</li>
<li><strong>2 links:</strong> Primary + Tertiary buttons (responsive layout)</li>
<li><strong>3+ links:</strong> All Tertiary buttons in block layout (vertical on all screen sizes)</li>
</ul>
</PageGridCol>
</PageGridRow>

View File

@@ -0,0 +1,495 @@
import * as React from "react";
import {
PageGrid,
PageGridRow,
PageGridCol,
} from "shared/components/PageGrid/page-grid";
import FeaturedVideoHero from "shared/patterns/FeaturedVideoHero/FeaturedVideoHero";
export const frontmatter = {
seo: {
title: "FeaturedVideoHero Pattern Showcase",
description:
"Interactive showcase of the FeaturedVideoHero pattern with video hero, CTAs, and responsive behavior.",
},
};
const DemoCaption = ({
title,
description,
code,
}: {
title: string;
description?: string;
code?: string;
}) => (
<div className="mb-6">
<h3 className="h4 mb-4">{title}</h3>
{description && <p className="mb-6">{description}</p>}
{code && (
<div
className="mb-6 p-4 bg-light br-4 text-black"
style={{
fontFamily: "monospace",
fontSize: "14px",
overflow: "auto",
}}
>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", color: "#000" }}>
{code}
</pre>
</div>
)}
</div>
);
const placeholderVideo =
"https://cdn.sanity.io/files/ior4a5y3/production/6e2fcba46e3f045a5570c86fd5d20d5ba93d6aad.mp4";
export default function FeaturedVideoHeroShowcase() {
const videoRef = React.useRef<HTMLVideoElement>(null);
React.useEffect(() => {
if (videoRef.current) {
console.log("FeaturedVideoHero video element:", videoRef.current);
}
}, []);
return (
<div className="landing">
<PageGrid className="py-26">
<PageGridRow>
<PageGridCol>
<div className="text-center mb-26">
<h6 className="eyebrow mb-3">Pattern Showcase</h6>
<h1 className="h2 mb-4">FeaturedVideoHero Pattern</h1>
<p className="longform">
A page-level hero pattern featuring a headline, optional
subtitle, call-to-action buttons, and a featured video. The
video uses native HTML video element props and is displayed in a
responsive two-column layout with content on the left and video
on the right.
</p>
</div>
</PageGridCol>
</PageGridRow>
{/* Basic Usage */}
<PageGridRow>
<PageGridCol>
<DemoCaption
title="Basic Usage with Video"
description="The simplest implementation with a headline, subtitle, primary CTA, and video. This example assigns a ref to the video element and logs it to the console on mount."
code={`const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (videoRef.current) {
console.log("FeaturedVideoHero video element:", videoRef.current);
}
}, []);
<FeaturedVideoHero
headline="Build on XRPL"
subtitle={
<>
<p>Issue, manage, and trade real-world assets without needing to build smart contracts.</p>
<p>XRP Ledger's built-in functionality and compliance-enabling features allow asset tokenization without additional layers of complexity.</p>
</>
}
callsToAction={[
{ children: "Get Started", href: "/docs" }
]}
videoElement={{
ref: videoRef,
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true
}}
/>`}
/>
</PageGridCol>
</PageGridRow>
</PageGrid>
<FeaturedVideoHero
headline="Build on XRPL"
subtitle={
<>
<p>
Issue, manage, and trade real-world assets without needing to
build smart contracts.
</p>
<p>
XRP Ledger's built-in functionality and compliance-enabling
features allow asset tokenization without additional layers of
complexity.
</p>
</>
}
callsToAction={[{ children: "Get Started", href: "/docs" }]}
videoElement={{
ref: videoRef,
src: placeholderVideo,
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
<PageGrid className="py-26">
{/* Primary + Secondary CTA */}
<PageGridRow>
<PageGridCol>
<DemoCaption
title="Primary and Secondary CTAs"
description="Include both primary and secondary call-to-action buttons."
code={`<FeaturedVideoHero
headline="Real-world asset tokenization"
subtitle="Learn how to issue crypto tokens and build tokenization solutions."
callsToAction={[
{ children: "Get Started", href: "/docs" },
{ children: "Learn More", href: "/about" }
]}
videoElement={{
src: "/video/tokenization.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true
}}
/>`}
/>
</PageGridCol>
</PageGridRow>
</PageGrid>
<FeaturedVideoHero
headline="Real-world asset tokenization"
subtitle="Learn how to issue crypto tokens and build tokenization solutions."
callsToAction={[
{ children: "Get Started", href: "/docs" },
{ children: "Learn More", href: "/about" },
]}
videoElement={{
src: placeholderVideo,
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
<PageGrid className="py-26">
{/* Video with extended props */}
<PageGridRow>
<PageGridCol>
<DemoCaption
title="Extended Video Props"
description="Use native video element props for controls, preload, poster, etc."
code={`<FeaturedVideoHero
headline="Watch and Learn"
subtitle="Explore our video tutorials and guides."
callsToAction={[
{ children: "Watch Tutorials", href: "/tutorials" }
]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
controls: true,
preload: "metadata"
}}
/>`}
/>
</PageGridCol>
</PageGridRow>
</PageGrid>
<FeaturedVideoHero
headline="Watch and Learn"
subtitle="Explore our video tutorials and guides."
callsToAction={[{ children: "Watch Tutorials", href: "/tutorials" }]}
videoElement={{
src: placeholderVideo,
autoPlay: false,
loop: true,
muted: true,
playsInline: true,
controls: true,
preload: "metadata",
}}
/>
<PageGrid className="py-26">
{/* Validation Examples */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Validation Examples</h2>
<p className="mb-6">
The component includes development-time validation that logs
warnings to the console when required props (headline,
videoElement) are missing. The component will return null and
not render when validation fails. The callsToAction prop is
optional; when provided, at least one non-empty CTA is needed to
show the CTA section.
</p>
</div>
</PageGridCol>
</PageGridRow>
{/* Primary CTA Only */}
<PageGridRow>
<PageGridCol>
<DemoCaption
title="Primary CTA Only (No Secondary)"
description="The secondary CTA is optional. When omitted, only the primary CTA button is displayed."
code={`<FeaturedVideoHero
headline="Single Call to Action"
subtitle="Focus on one primary action for better conversion."
callsToAction={[
{ children: "Get Started", href: "/docs" }
]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true
}}
/>`}
/>
</PageGridCol>
</PageGridRow>
</PageGrid>
<FeaturedVideoHero
headline="Single Call to Action"
subtitle="Focus on one primary action for better conversion."
callsToAction={[{ children: "Get Started", href: "/docs" }]}
videoElement={{
src: placeholderVideo,
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
<PageGrid className="py-26">
{/* Without subtitle */}
<PageGridRow>
<PageGridCol>
<DemoCaption
title="Without Subtitle"
description="Subtitle is optional. The component renders without a subtitle section when omitted."
code={`<FeaturedVideoHero
headline="Headline Only"
callsToAction={[
{ children: "Get Started", href: "/docs" }
]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true
}}
/>`}
/>
</PageGridCol>
</PageGridRow>
</PageGrid>
<FeaturedVideoHero
headline="Headline Only"
callsToAction={[{ children: "Get Started", href: "/docs" }]}
videoElement={{
src: placeholderVideo,
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
<PageGrid className="py-26">
{/* Props Documentation */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Props Documentation</h2>
<h4 className="h5 mb-4">FeaturedVideoHeroProps</h4>
<div className="mb-6">
<ul>
<li>
<code>headline</code> (required) -{" "}
<code>React.ReactNode</code> - Hero headline text
</li>
<li>
<code>subtitle</code> (optional) -{" "}
<code>React.ReactNode</code> - Hero subtitle text
</li>
<li>
<code>callsToAction</code> (optional) -{" "}
<code>DesignConstrainedCallsToActions</code> - Array with
primary CTA and optional secondary CTA. Omit or pass
empty/non-rendering CTAs to hide the CTA section.
</li>
<li>
<code>videoElement</code> (required) - Native{" "}
<code>&lt;video&gt;</code> element props (e.g. src,
autoPlay, loop, muted, playsInline, controls, preload,
poster)
</li>
<li>
<code>className</code> (optional) - <code>string</code> -
Additional CSS classes for the header element
</li>
<li>
All standard HTML <code>&lt;header&gt;</code> attributes are
supported
</li>
</ul>
</div>
<h4 className="h5 mb-4">Button Props (callsToAction)</h4>
<p className="mb-4">
The <code>callsToAction</code> prop accepts design-constrained
Button props; <code>variant</code> and <code>color</code> are
set by the component:
</p>
<ul>
<li>
Primary CTA: <code>variant="primary"</code>,{" "}
<code>color="green"</code>
</li>
<li>
Secondary CTA: <code>variant="tertiary"</code>,{" "}
<code>color="green"</code>
</li>
<li>
All other Button props are supported (e.g.,{" "}
<code>children</code>, <code>href</code>, <code>onClick</code>
, etc.)
</li>
</ul>
</div>
</PageGridCol>
</PageGridRow>
{/* Code Examples */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Code Examples</h2>
<h4 className="h5 mb-4">Import</h4>
<div
className="p-4 bg-light br-4 mb-6"
style={{ fontFamily: "monospace", fontSize: "14px" }}
>
<pre style={{ margin: 0, color: "#000" }}>
{`import { FeaturedVideoHero } from "shared/patterns/FeaturedVideoHero";`}
</pre>
</div>
<h4 className="h5 mb-4">Basic Example</h4>
<div
className="p-4 bg-light br-4 mb-6"
style={{
fontFamily: "monospace",
fontSize: "14px",
overflow: "auto",
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
}}
>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{`<FeaturedVideoHero
headline="Build on XRPL"
subtitle={
<>
<p>Issue, manage, and trade real-world assets without needing to build smart contracts.</p>
<p>XRP Ledger's built-in functionality and compliance-enabling features allow asset tokenization without additional layers of complexity.</p>
</>
}
callsToAction={[
{ children: "Get Started", href: "/docs" }
]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true
}}
/>`}
</pre>
</div>
<h4 className="h5 mb-4">With Secondary CTA</h4>
<div
className="p-4 bg-light br-4 mb-6"
style={{
fontFamily: "monospace",
fontSize: "14px",
overflow: "auto",
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
}}
>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{`<FeaturedVideoHero
headline="Real-world asset tokenization"
subtitle="Learn how to issue crypto tokens and build solutions."
callsToAction={[
{ children: "Get Started", href: "/docs" },
{ children: "Learn More", href: "/about" }
]}
videoElement={{
src: "/video/tokenization.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true
}}
/>`}
</pre>
</div>
</div>
</PageGridCol>
</PageGridRow>
{/* Best Practices */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Best Practices</h2>
<ul>
<li>
<strong>Video format:</strong> Use MP4 with H.264 for broad
compatibility. Keep file sizes reasonable for fast loading.
</li>
<li>
<strong>Autoplay:</strong> Use <code>muted</code> and{" "}
<code>playsInline</code> with <code>autoPlay</code> for
reliable autoplay on mobile.
</li>
<li>
<strong>CTAs:</strong> Keep CTA text concise and
action-oriented. Primary CTA should be the main action.
</li>
<li>
<strong>Headlines:</strong> Keep headlines concise and
impactful. Use the subtitle for additional context.
</li>
<li>
<strong>Responsive design:</strong> The layout stacks on
smaller screens; test across breakpoints to ensure video and
content display correctly.
</li>
</ul>
</div>
</PageGridCol>
</PageGridRow>
</PageGrid>
</div>
);
}

View File

@@ -64,14 +64,10 @@ export default function LogoSquareGridShowcase() {
variant="green"
heading="Developer tools & APIs"
description="Streamline development and build powerful RWA tokenization solutions with XRP Ledger's comprehensive developer toolset."
primaryButton={{
label: "View Documentation",
onClick: () => handleClick('green-primary')
}}
tertiaryButton={{
label: "Explore Tools",
onClick: () => handleClick('green-tertiary')
}}
buttons={[
{ label: "View Documentation", onClick: () => handleClick('green-primary') },
{ label: "Explore Tools", onClick: () => handleClick('green-tertiary') }
]}
logos={sampleLogos}
/>
@@ -150,8 +146,10 @@ export default function LogoSquareGridShowcase() {
variant="gray"
heading="Our Partners"
description="Leading companies building innovative solutions on the XRP Ledger."
primaryButton={{ label: "View All Partners", href: "#partners" }}
tertiaryButton={{ label: "Become a Partner", href: "#partner-program" }}
buttons={[
{ label: "View All Partners", href: "#partners" },
{ label: "Become a Partner", href: "#partner-program" }
]}
logos={sampleLogos}
/>
</div>
@@ -173,7 +171,9 @@ export default function LogoSquareGridShowcase() {
variant="green"
heading="Featured Integrations"
description="Connect with leading platforms and services built on XRPL."
primaryButton={{ label: "See All Integrations", href: "#integrations" }}
buttons={[
{ label: "See All Integrations", href: "#integrations" }
]}
logos={sampleLogos}
/>
</div>
@@ -247,7 +247,9 @@ export default function LogoSquareGridShowcase() {
variant="green"
heading="Developer Resources"
description="Access comprehensive tools and libraries for building on XRPL."
primaryButton={{ label: "Get Started", onClick: () => handleClick('single-button') }}
buttons={[
{ label: "Get Started", onClick: () => handleClick('single-button') }
]}
logos={smallLogoSet}
/>
</div>
@@ -431,20 +433,12 @@ export default function LogoSquareGridShowcase() {
<div style={{ flex: '1 1 0', minWidth: 0 }}>Optional description text</div>
</div>
{/* primaryButton */}
{/* buttons */}
<div className="d-flex flex-row py-3" style={{ gap: '1rem', borderBottom: '1px solid var(--bs-border-color, #dee2e6)' }}>
<div style={{ width: '140px', flexShrink: 0 }}><code>primaryButton</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}><code>{`{ label, href?, onClick? }`}</code></div>
<div style={{ width: '140px', flexShrink: 0 }}><code>buttons</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}><code>{`Array<{ label, href?, onClick?, forceColor? }>`}</code></div>
<div style={{ width: '120px', flexShrink: 0 }}><code>undefined</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}>Primary button configuration</div>
</div>
{/* tertiaryButton */}
<div className="d-flex flex-row py-3" style={{ gap: '1rem', borderBottom: '1px solid var(--bs-border-color, #dee2e6)' }}>
<div style={{ width: '140px', flexShrink: 0 }}><code>tertiaryButton</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}><code>{`{ label, href?, onClick? }`}</code></div>
<div style={{ width: '120px', flexShrink: 0 }}><code>undefined</code></div>
<div style={{ flex: '1 1 0', minWidth: 0 }}>Tertiary button configuration</div>
<div style={{ flex: '1 1 0', minWidth: 0 }}>Button configurations (1-2 buttons supported)</div>
</div>
{/* logos */}

View File

@@ -1900,4 +1900,34 @@ html.dark {
}
}
}
}
// =============================================================================
// No Padding Modifier
// =============================================================================
// When .bds-btn--no-padding is applied, removes all padding and left-aligns content.
// Useful for tertiary buttons in block layouts where left alignment is needed.
// =============================================================================
.bds-btn--no-padding {
padding: 0 !important;
justify-content: flex-start !important;
// Override all state paddings
&:hover:not(:disabled):not(.bds-btn--disabled),
&:focus-visible:not(:disabled):not(.bds-btn--disabled),
&:active:not(:disabled):not(.bds-btn--disabled) {
padding: 0 !important;
}
// Responsive overrides
@include media-breakpoint-down(xl) {
padding: 0 !important;
&:hover:not(:disabled):not(.bds-btn--disabled),
&:focus-visible:not(:disabled):not(.bds-btn--disabled),
&:active:not(:disabled):not(.bds-btn--disabled) {
padding: 0 !important;
}
}
}

View File

@@ -31,6 +31,12 @@ export interface ButtonProps {
href?: string;
/** Link target - only applies when href is provided */
target?: '_self' | '_blank';
/**
* Force no padding and left-align text.
* When true, removes all padding and aligns content to the left.
* Useful for tertiary buttons in block layouts where left alignment is needed.
*/
forceNoPadding?: boolean;
}
/**
@@ -115,6 +121,7 @@ export const Button: React.FC<ButtonProps> = ({
ariaLabel,
href,
target = '_self',
forceNoPadding = false,
}) => {
// Hide icon when disabled (per design spec)
const shouldShowIcon = showIcon && !disabled;
@@ -131,6 +138,7 @@ export const Button: React.FC<ButtonProps> = ({
'bds-btn--disabled': disabled,
'bds-btn--no-icon': !shouldShowIcon,
'bds-btn--force-color': forceColor,
'bds-btn--no-padding': forceNoPadding,
},
className
);

View File

@@ -6,6 +6,7 @@ import {
isEnvironment,
isEmpty,
} from "../../utils";
import { DesignConstrainedCallToActionsProps } from "shared/utils/types";
/**
* Available background color variants for StandardCard:
@@ -16,11 +17,13 @@ import {
*/
export type StandardCardVariant = "neutral" | "green" | "yellow" | "blue";
export interface StandardCardProps extends React.ComponentPropsWithoutRef<"article"> {
export interface StandardCardProps
extends
React.ComponentPropsWithoutRef<"article">,
DesignConstrainedCallToActionsProps {
headline: React.ReactNode;
/** Background color variant */
variant: StandardCardVariant;
callsToAction: [DesignConstrainedButtonProps, DesignConstrainedButtonProps?];
children?: React.ReactNode;
}
@@ -46,7 +49,7 @@ const StandardCard = forwardRef<HTMLElement, StandardCardProps>(
const [primaryButton, secondaryButton] = callsToAction;
const hasButtons = callsToAction.some((button) => button !== undefined);
const hasButtons = callsToAction.some((button) => !isEmpty(button));
if (!headline) {
if (isEnvironment("development")) {

View File

@@ -5,6 +5,7 @@
// .bds-button-group - Base component
// .bds-button-group--gap-none - No gap between buttons on tablet+ (0px)
// .bds-button-group--gap-small - Small gap between buttons on tablet+ (8px)
// .bds-button-group--block - Block layout for 3+ buttons (all tertiary)
// =============================================================================
// Base Component Styles
@@ -13,8 +14,8 @@
.bds-button-group {
@extend .d-flex;
@extend .flex-column;
@extend .align-items-start;
@extend .flex-wrap;
align-items: start;
gap: 8px;
// Tablet breakpoint - horizontal layout
@@ -41,3 +42,18 @@
gap: 4px;
}
}
// =============================================================================
// Block Layout Modifier (3+ buttons)
// =============================================================================
.bds-button-group--block {
// Override default flex layout - force column layout on all screen sizes
flex-direction: column !important;
gap: 16px !important;
// All buttons should be full width in block layout
.bds-btn {
width: 100%;
}
}

View File

@@ -13,84 +13,212 @@ export interface ButtonConfig {
onClick?: () => void;
}
export interface ButtonGroupValidationResult {
/** The validated and potentially trimmed list of buttons */
buttons: ButtonConfig[];
/** Whether the button list is valid and should render */
isValid: boolean;
/** Any warnings generated during validation */
warnings: string[];
}
/**
* Validates and processes a ButtonConfig array for ButtonGroup.
*
* Performs the following validations:
* - Applies maxButtons limit if specified
* - Checks for empty button arrays
* - Validates individual button configs (label required, href or onClick recommended)
*
* @param buttons - Array of button configurations
* @param maxButtons - Optional maximum number of buttons to render
* @returns Validation result with processed buttons, validity flag, and warnings
*
* @example
* const result = validateButtonGroup(buttons, 2);
* if (!result.isValid) return null;
* // Use result.buttons for rendering
*/
export function validateButtonGroup(
buttons: ButtonConfig[],
maxButtons?: number
): ButtonGroupValidationResult {
const warnings: string[] = [];
let buttonList = [...buttons];
// Validate individual button configs
buttonList.forEach((button, index) => {
if (!button.label || button.label.trim() === '') {
warnings.push(
`[ButtonGroup] Button at index ${index} is missing a label. This button may not render correctly.`
);
}
if (!button.href && !button.onClick) {
warnings.push(
`[ButtonGroup] Button "${button.label || `at index ${index}`}" has no href or onClick. Consider adding an action.`
);
}
});
// Apply maxButtons limit if specified
if (maxButtons !== undefined && maxButtons > 0 && buttons.length > maxButtons) {
warnings.push(
`[ButtonGroup] ${buttons.length} buttons were passed but maxButtons is set to ${maxButtons}. ` +
`Only the first ${maxButtons} button(s) will be rendered.`
);
buttonList = buttonList.slice(0, maxButtons);
}
// Check for empty array
if (buttonList.length === 0) {
warnings.push(
`[ButtonGroup] No buttons to render. ` +
`Either an empty buttons array was passed or all buttons were removed by maxButtons limit.`
);
return { buttons: [], isValid: false, warnings };
}
return { buttons: buttonList, isValid: true, warnings };
}
export interface ButtonGroupProps {
/** Primary button configuration */
primaryButton?: ButtonConfig;
/** Tertiary button configuration */
tertiaryButton?: ButtonConfig;
/** Array of button configurations
* - 1 button: renders with singleButtonVariant (default: primary)
* - 2 buttons: first as primary, second as tertiary
* - 3+ buttons: all tertiary in block layout
*/
buttons: ButtonConfig[];
/** Button color theme */
color?: 'green' | 'black';
/** Whether to force the color to remain constant regardless of theme mode */
forceColor?: boolean;
forceColor?: boolean;
/** Gap between buttons on tablet+ (0px or 4px) */
gap?: 'none' | 'small';
/** Additional CSS classes */
className?: string;
/** Override variant for single button (default: 'primary', can be 'secondary') */
singleButtonVariant?: 'primary' | 'secondary';
/** Maximum number of buttons to render. If more buttons are passed, only the first N will be rendered. */
maxButtons?: number;
}
/**
* ButtonGroup Component
*
* A responsive button group container that displays primary and/or tertiary buttons.
* Stacks vertically on mobile and horizontally on tablet+.
*
*
* A responsive button group container that displays buttons with adaptive layout:
* - 1 button: Renders with singleButtonVariant (default: primary, can be secondary)
* - 2 buttons: First as primary, second as tertiary (responsive layout)
* - 3+ buttons: All tertiary in block layout
*
* @example
* // Basic usage with both buttons
* // Single button
* <ButtonGroup
* primaryButton={{ label: "Get Started", href: "/start" }}
* tertiaryButton={{ label: "Learn More", href: "/learn" }}
* buttons={[{ label: "Get Started", href: "/start" }]}
* color="green"
* />
*
*
* @example
* // With custom gap
* // Two buttons (primary + tertiary)
* <ButtonGroup
* primaryButton={{ label: "Action", onClick: handleClick }}
* color="black"
* gap="small"
* buttons={[
* { label: "Get Started", href: "/start" },
* { label: "Learn More", href: "/learn" }
* ]}
* color="green"
* />
*
* @example
* // Three or more buttons (all tertiary, block layout)
* <ButtonGroup
* buttons={[
* { label: "Option 1", href: "/option1" },
* { label: "Option 2", href: "/option2" },
* { label: "Option 3", href: "/option3" }
* ]}
* color="green"
* />
*/
export const ButtonGroup: React.FC<ButtonGroupProps> = ({
primaryButton,
tertiaryButton,
buttons,
color = 'green',
forceColor = false,
gap = 'small',
className = '',
singleButtonVariant = 'primary',
maxButtons,
}) => {
// Don't render if no buttons are provided
if (!primaryButton && !tertiaryButton) {
// Validate and process buttons
const validation = validateButtonGroup(buttons, maxButtons);
// Log warnings in development mode
if (process.env.NODE_ENV === 'development' && validation.warnings.length > 0) {
validation.warnings.forEach(warning => console.warn(warning));
}
// Don't render if validation failed
if (!validation.isValid) {
return null;
}
const buttonList = validation.buttons;
const isMultiButton = buttonList.length >= 3;
const classNames = clsx(
'bds-button-group',
`bds-button-group--gap-${gap}`,
{
'bds-button-group--block': isMultiButton,
},
className
);
// Render 3+ buttons: all tertiary in block layout
if (isMultiButton) {
return (
<div className={classNames}>
{buttonList.map((button, index) => (
<Button
key={index}
variant="tertiary"
color={color}
forceColor={forceColor}
href={button.href}
onClick={button.onClick}
forceNoPadding
>
{button.label}
</Button>
))}
</div>
);
}
// Render 1-2 buttons
// Single button: use singleButtonVariant (default: primary, can be secondary)
// Two buttons: first as primary, second as tertiary
const firstButtonVariant = buttonList.length === 1 ? singleButtonVariant : 'primary';
return (
<div className={classNames}>
{primaryButton && (
{buttonList[0] && (
<Button
variant="primary"
variant={firstButtonVariant}
color={color}
forceColor={forceColor}
href={primaryButton.href}
onClick={primaryButton.onClick}
{...buttonList[0]}
>
{primaryButton.label}
{buttonList[0].label}
</Button>
)}
{tertiaryButton && (
{buttonList[1] && (
<Button
variant="tertiary"
color={color}
forceColor={forceColor}
href={tertiaryButton.href}
onClick={tertiaryButton.onClick}
{...buttonList[1]}
>
{tertiaryButton.label}
{buttonList[1].label}
</Button>
)}
</div>

View File

@@ -1,38 +1,76 @@
# ButtonGroup Component
A responsive button group container that displays primary and/or tertiary buttons. Stacks vertically on mobile and horizontally on tablet+.
A responsive button group container that automatically assigns button variants based on the number of buttons passed. Stacks vertically on mobile and horizontally on tablet+.
## Features
- **Responsive Layout**: Vertical stack on mobile, horizontal row on tablet+
- **Flexible Configuration**: Support for primary, tertiary, or both buttons
- **Auto-Variant Assignment**: Automatically assigns Primary/Tertiary/Secondary variants based on button count
- **Responsive Layout**: Vertical stack on mobile, horizontal row on tablet+ (for 1-2 buttons)
- **Block Layout**: 3+ buttons render as all tertiary in a vertical block layout
- **Customizable Spacing**: Control gap between buttons on tablet+ (none or small)
- **Theme Support**: Green or black color themes
- **Max Buttons Limit**: Optionally limit the number of buttons rendered
## Button Behavior
The component automatically determines button variants based on count:
| Count | Behavior |
|-------|----------|
| 1 button | Renders as Primary (or Secondary with `singleButtonVariant="secondary"`) |
| 2 buttons | First as Primary, second as Tertiary (responsive layout) |
| 3+ buttons | All as Tertiary in block layout (vertical on all screen sizes) |
## Usage
```tsx
import { ButtonGroup } from 'shared/patterns/ButtonGroup';
// Basic usage with both buttons
// Single button (Primary by default)
<ButtonGroup
primaryButton={{ label: "Get Started", href: "/start" }}
tertiaryButton={{ label: "Learn More", href: "/learn" }}
buttons={[
{ label: "Get Started", href: "/start" }
]}
color="green"
/>
// With no gap on tablet+
// Single button as Secondary
<ButtonGroup
primaryButton={{ label: "Action", onClick: handleClick }}
color="black"
gap="none"
buttons={[
{ label: "Learn More", href: "/learn" }
]}
singleButtonVariant="secondary"
color="green"
/>
// With small gap on tablet+ (4px - default)
// Two buttons (auto: Primary + Tertiary)
<ButtonGroup
primaryButton={{ label: "Primary Action", href: "/action" }}
tertiaryButton={{ label: "Secondary", href: "/secondary" }}
gap="small"
buttons={[
{ label: "Get Started", href: "/start" },
{ label: "Learn More", href: "/learn" }
]}
color="green"
/>
// Three or more buttons (auto: all Tertiary, block layout)
<ButtonGroup
buttons={[
{ label: "Documentation", href: "/docs" },
{ label: "API Reference", href: "/api" },
{ label: "Tutorials", href: "/tutorials" }
]}
color="black"
/>
// Limit to 2 buttons even if more are passed
<ButtonGroup
buttons={[
{ label: "First", href: "/first" },
{ label: "Second", href: "/second" },
{ label: "Third (not rendered)", href: "/third" }
]}
maxButtons={2}
color="green"
/>
```
@@ -40,10 +78,12 @@ import { ButtonGroup } from 'shared/patterns/ButtonGroup';
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `primaryButton` | `ButtonConfig` | - | Primary button configuration |
| `tertiaryButton` | `ButtonConfig` | - | Tertiary button configuration |
| `buttons` | `ButtonConfig[]` | *required* | Array of button configurations |
| `color` | `'green' \| 'black'` | `'green'` | Button color theme |
| `forceColor` | `boolean` | `false` | Force color to remain constant across light/dark modes |
| `gap` | `'none' \| 'small'` | `'small'` | Gap between buttons on tablet+ (0px or 4px) |
| `singleButtonVariant` | `'primary' \| 'secondary'` | `'primary'` | Variant for single button |
| `maxButtons` | `number` | - | Maximum number of buttons to render |
| `className` | `string` | `''` | Additional CSS classes |
### ButtonConfig
@@ -53,6 +93,7 @@ interface ButtonConfig {
label: string;
href?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
forceColor?: boolean;
}
```

View File

@@ -1,7 +1,7 @@
import React from 'react';
import clsx from 'clsx';
import { PageGrid, PageGridCol, PageGridRow } from 'shared/components/PageGrid/page-grid';
import { ButtonGroup } from '../ButtonGroup/ButtonGroup';
import { ButtonGroup, ButtonConfig, validateButtonGroup } from '../ButtonGroup/ButtonGroup';
export interface CalloutMediaBannerProps {
/** Color variant - determines background color (ignored if backgroundImage is provided) */
@@ -14,18 +14,8 @@ export interface CalloutMediaBannerProps {
heading?: string;
/** Subheading/description text */
subheading: string;
/** Primary button configuration */
primaryButton?: {
label: string;
href?: string;
onClick?: () => void;
};
/** Tertiary button configuration */
tertiaryButton?: {
label: string;
href?: string;
onClick?: () => void;
};
/** Button configurations (1-2 buttons supported) */
buttons?: ButtonConfig[];
/** Additional CSS classes */
className?: string;
}
@@ -42,19 +32,21 @@ export interface CalloutMediaBannerProps {
* variant="green"
* heading="The Compliant Ledger Protocol"
* subheading="A decentralized public Layer 1 blockchain..."
* primaryButton={{ label: "Get Started", href: "/docs" }}
* tertiaryButton={{ label: "Learn More", href: "/about" }}
* buttons={[
* { label: "Get Started", href: "/docs" },
* { label: "Learn More", href: "/about" }
* ]}
* />
*
*
* @example
* // With background image (white text - default)
* <CalloutMediaBanner
* backgroundImage="/images/hero-bg.jpg"
* heading="Build on XRPL"
* subheading="Start building your next project"
* primaryButton={{ label: "Start Building", onClick: handleClick }}
* buttons={[{ label: "Start Building", onClick: handleClick }]}
* />
*
*
* @example
* // With background image and black text (fixed across light/dark modes)
* <CalloutMediaBanner
@@ -62,7 +54,7 @@ export interface CalloutMediaBannerProps {
* textColor="black"
* heading="Build on XRPL"
* subheading="Start building your next project"
* primaryButton={{ label: "Start Building", onClick: handleClick }}
* buttons={[{ label: "Start Building", onClick: handleClick }]}
* />
*/
export const CalloutMediaBanner: React.FC<CalloutMediaBannerProps> = ({
@@ -71,13 +63,20 @@ export const CalloutMediaBanner: React.FC<CalloutMediaBannerProps> = ({
textColor = 'white',
heading,
subheading,
primaryButton,
tertiaryButton,
buttons,
className = '',
}) => {
// Check if there are any buttons
const hasButtons = !!(primaryButton || tertiaryButton);
// Validate buttons if provided (max 2 buttons supported)
const buttonValidation = buttons ? validateButtonGroup(buttons, 2) : null;
// Log warnings in development mode
if (process.env.NODE_ENV === 'development' && buttonValidation?.warnings.length) {
buttonValidation.warnings.forEach(warning => console.warn(warning));
}
// Check if there are any valid buttons
const hasButtons = buttonValidation?.isValid && buttonValidation.buttons.length > 0;
// Check if we should center content: no buttons OR (no heading but has buttons)
const shouldCenter = !hasButtons || (!heading && hasButtons);
@@ -116,12 +115,13 @@ export const CalloutMediaBanner: React.FC<CalloutMediaBannerProps> = ({
</div>
{/* Buttons */}
<ButtonGroup
primaryButton={primaryButton}
tertiaryButton={tertiaryButton}
color={buttonColor}
gap="none"
/>
{hasButtons && (
<ButtonGroup
buttons={buttonValidation.buttons}
color={buttonColor}
gap="none"
/>
)}
</div>
</PageGridCol>
</PageGridRow>

View File

@@ -29,7 +29,8 @@ $bds-carousel-grid-padding-lg: 32px; // Desktop (lg+)
$bds-carousel-grid-max-width: 1280px; // Max container width (per _breakpoints.scss $xl)
// Spacing - Header gap (between heading and description)
$bds-carousel-header-gap-base: 8px; // Base
$bds-carousel-header-gap-sm: 8px; // Mobile
$bds-carousel-header-gap-md: 8px; // Tablet
$bds-carousel-header-gap-lg: 16px; // Desktop
// Spacing - Section gap (between header content and buttons row on mobile)
@@ -108,16 +109,31 @@ $bds-carousel-transition: 200ms cubic-bezier(0.98, 0.12, 0.12, 0.98);
.bds-carousel-card-list__header-content {
display: flex;
flex-direction: column;
gap: $bds-carousel-header-gap-base;
gap: $bds-carousel-header-gap-sm;
// Full width on mobile and tablet
max-width: 100%;
@include media-breakpoint-up(md) {
gap: $bds-carousel-header-gap-md;
}
// Constrain heading/description to grid (8 columns at desktop)
@include media-breakpoint-up(lg) {
gap: $bds-carousel-header-gap-lg;
max-width: 808px; // Desktop: 8 columns
}
}
.bds-carousel-card-list__heading {
margin: 0;
// Typography handled by .h-md class from _font.scss
}
.bds-carousel-card-list__description {
margin: 0;
// Typography handled by .body-l class from _font.scss
}
// =============================================================================
// Navigation Buttons Container
// =============================================================================
@@ -127,16 +143,9 @@ $bds-carousel-transition: 200ms cubic-bezier(0.98, 0.12, 0.12, 0.98);
gap: $bds-carousel-button-gap;
justify-content: flex-end;
flex-shrink: 0;
margin-top: 24px;
@include media-breakpoint-up(md) {
margin-top: 32px;
}
// Constrain heading/description to grid (8 columns at desktop)
@include media-breakpoint-up(lg) {
margin-top: 40px;
}
// Add padding to allow focus ring to be visible without clipping
padding: 4px;
margin: -4px;
}
// =============================================================================
@@ -147,16 +156,16 @@ $bds-carousel-transition: 200ms cubic-bezier(0.98, 0.12, 0.12, 0.98);
margin-top: $bds-carousel-cards-gap-sm;
overflow: visible;
// Add left padding here so it's OUTSIDE the scrollable area
// padding-left: $bds-carousel-grid-padding-sm;
padding-left: $bds-carousel-grid-padding-sm;
@include media-breakpoint-up(md) {
margin-top: $bds-carousel-cards-gap-md;
// padding-left: $bds-carousel-grid-padding-md;
padding-left: $bds-carousel-grid-padding-md;
}
@include media-breakpoint-up(lg) {
margin-top: $bds-carousel-cards-gap-lg;
// padding-left: $bds-carousel-grid-padding-lg;
padding-left: $bds-carousel-grid-padding-lg;
}
}
@@ -260,19 +269,6 @@ $bds-carousel-transition: 200ms cubic-bezier(0.98, 0.12, 0.12, 0.98);
}
}
.bds-carousel-card-list__grid {
gap: $bds-carousel-section-gap-sm;
@include media-breakpoint-up(md) {
gap: $bds-carousel-section-gap-md;
}
// Row layout only at desktop (lg and up)
@include media-breakpoint-up(lg) {
gap: $bds-carousel-section-gap-lg;
}
}
// =============================================================================
// LIGHT MODE (html.light) - Color Variants
// =============================================================================

View File

@@ -3,7 +3,6 @@ import clsx from 'clsx';
import { CardOffgrid, CardOffgridProps } from '../../components/CardOffgrid';
import { CarouselButton } from '../../components/CarouselButton';
import type { ButtonProps } from '../../components/Button';
import { PageGrid } from 'shared/components/PageGrid/page-grid';
/**
* Configuration for a single card in the CarouselCardList pattern
@@ -122,55 +121,45 @@ export const CarouselCardList = React.forwardRef<HTMLElement, CarouselCardListPr
return (
<section
ref={ref}
className={clsx(`bds-carousel-card-list--${variant}`, className)}
className={clsx('bds-carousel-card-list', `bds-carousel-card-list--${variant}`, className)}
{...rest}
>
<PageGrid className="bds-carousel-card-list__grid">
{/* Header with title, description, and navigation buttons */}
<PageGrid.Row>
<PageGrid.Col span={{ base: "fill", md: 6, lg: 8 }}>
<div className="bds-carousel-card-list__header-content">
<h2 className="mb-0 h-md">{heading}</h2>
<p className="mb-0 body-l">{description}</p>
</div>
</PageGrid.Col>
</PageGrid.Row>
<PageGrid.Row>
<PageGrid.Col className="bds-carousel-card-list__nav">
<CarouselButton
direction="prev"
variant={buttonVariant}
disabled={!canScrollPrev}
onClick={() => scroll('prev')}
aria-label="Previous cards"
/>
<CarouselButton
direction="next"
variant={buttonVariant}
disabled={!canScrollNext}
onClick={() => scroll('next')}
aria-label="Next cards"
/>
</PageGrid.Col>
</PageGrid.Row>
{/* Cards scroll container - full bleed */}
<div className="bds-carousel-card-list__track-wrapper">
<div
ref={scrollContainerRef}
className="bds-carousel-card-list__track"
role="region"
aria-label="Card carousel"
tabIndex={0}
>
{cards.map((card, index) => (
<div key={getCardKey(card, index)} className={CARD_CLASS_NAME}>
<CardOffgrid {...card} variant={variant} />
</div>
))}
</div>
{/* Header with title, description, and navigation buttons */}
<div className="bds-carousel-card-list__header">
<div className="bds-carousel-card-list__header-content">
<h2 className="bds-carousel-card-list__heading h-md">{heading}</h2>
<p className="bds-carousel-card-list__description body-l">{description}</p>
</div>
</PageGrid>
<div className="bds-carousel-card-list__nav">
{(['prev', 'next'] as const).map((direction) => (
<CarouselButton
key={direction}
direction={direction}
variant={buttonVariant}
disabled={direction === 'prev' ? !canScrollPrev : !canScrollNext}
onClick={() => scroll(direction)}
aria-label={direction === 'prev' ? 'Previous cards' : 'Next cards'}
/>
))}
</div>
</div>
{/* Cards scroll container - full bleed */}
<div className="bds-carousel-card-list__track-wrapper">
<div
ref={scrollContainerRef}
className="bds-carousel-card-list__track"
role="region"
aria-label="Card carousel"
tabIndex={0}
>
{cards.map((card, index) => (
<div key={getCardKey(card, index)} className={CARD_CLASS_NAME}>
<CardOffgrid {...card} variant={variant} />
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import clsx from 'clsx';
import { Button } from '../../components/Button';
import { CarouselButton } from '../../components/CarouselButton';
import { Divider } from '../../components/Divider';
import { PageGrid, PageGridRow, PageGridCol } from '../../components/PageGrid';
import { ButtonConfig } from '../ButtonGroup';
import { ButtonGroup, ButtonConfig } from '../ButtonGroup';
/**
* Props for a single slide in the CarouselFeatured component
@@ -182,20 +181,16 @@ export const CarouselFeatured = React.forwardRef<HTMLElement, CarouselFeaturedPr
<div className="bds-carousel-featured__header">
<h2 className="bds-carousel-featured__heading h-md">{heading}</h2>
<div className="bds-carousel-featured__nav bds-carousel-featured__nav--desktop">
<CarouselButton
direction="prev"
variant={buttonVariant}
disabled={!canGoPrev}
onClick={goToPrev}
aria-label="Previous slide"
/>
<CarouselButton
direction="next"
variant={buttonVariant}
disabled={!canGoNext}
onClick={goToNext}
aria-label="Next slide"
/>
{(['prev', 'next'] as const).map((direction) => (
<CarouselButton
key={direction}
direction={direction}
variant={buttonVariant}
disabled={direction === 'prev' ? !canGoPrev : !canGoNext}
onClick={direction === 'prev' ? goToPrev : goToNext}
aria-label={direction === 'prev' ? 'Previous slide' : 'Next slide'}
/>
))}
</div>
</div>
@@ -221,52 +216,27 @@ export const CarouselFeatured = React.forwardRef<HTMLElement, CarouselFeaturedPr
primaryButton && tertiaryButton && 'bds-carousel-featured__cta--two-buttons'
)}>
{/* Buttons wrapper - groups primary and tertiary together */}
<div className="bds-carousel-featured__buttons">
{/* Primary button */}
{primaryButton && (
<Button
variant="primary"
color="black"
forceColor={background !== 'neutral'}
href={primaryButton.href}
onClick={primaryButton.onClick}
className="bds-carousel-featured__primary-btn"
>
{primaryButton.label}
</Button>
)}
{/* Tertiary button */}
{tertiaryButton && (
<Button
variant="tertiary"
color="black"
forceColor={background !== 'neutral'}
href={tertiaryButton.href}
onClick={tertiaryButton.onClick}
className="bds-carousel-featured__tertiary-btn"
>
{tertiaryButton.label}
</Button>
)}
</div>
{(primaryButton || tertiaryButton) && (
<ButtonGroup
buttons={[primaryButton, tertiaryButton].filter((btn): btn is ButtonConfig => !!btn)}
color="black"
forceColor={background !== 'neutral'}
className="bds-carousel-featured__buttons"
/>
)}
{/* Mobile/Tablet nav buttons */}
<div className="bds-carousel-featured__nav bds-carousel-featured__nav--mobile">
<CarouselButton
direction="prev"
variant={buttonVariant}
disabled={!canGoPrev}
onClick={goToPrev}
aria-label="Previous slide"
/>
<CarouselButton
direction="next"
variant={buttonVariant}
disabled={!canGoNext}
onClick={goToNext}
aria-label="Next slide"
/>
{(['prev', 'next'] as const).map((direction) => (
<CarouselButton
key={direction}
direction={direction}
variant={buttonVariant}
disabled={direction === 'prev' ? !canGoPrev : !canGoNext}
onClick={direction === 'prev' ? goToPrev : goToNext}
aria-label={direction === 'prev' ? 'Previous slide' : 'Next slide'}
/>
))}
</div>
</div>
</div> {/* Close bottom */}

View File

@@ -40,7 +40,6 @@ $bds-feature-desktop-py: 96px;
$bds-feature-desktop-text-gap: 16px;
$bds-feature-desktop-cta-gap-col: 0; // Gap between button rows
$bds-feature-desktop-content-gap: 0; // Gap between text-group and cta (space-between handles this)
$bds-feature-desktop-button-gap: 16px; // Gap between buttons in ButtonGroup (consistent across all sizes)
// Spacing - Tablet (576px - 991px) - based on Figma 768px design
$bds-feature-tablet-py: 80px;
@@ -50,7 +49,6 @@ $bds-feature-tablet-text-gap: 8px;
$bds-feature-tablet-cta-gap-row: 16px; // Gap between buttons in row from Figma
$bds-feature-tablet-cta-gap-col: 0;
$bds-feature-tablet-content-gap: 32px;
$bds-feature-tablet-button-gap: 16px; // Gap between buttons in ButtonGroup (consistent across all sizes)
// Spacing - Mobile (<576px) - based on Figma 375px design
$bds-feature-mobile-py: 64px;
@@ -58,7 +56,6 @@ $bds-feature-mobile-px: 16px;
$bds-feature-mobile-text-gap: 8px;
$bds-feature-mobile-cta-gap: 16px; // Gap between stacked buttons from Figma
$bds-feature-mobile-content-gap: 24px;
$bds-feature-mobile-button-gap: 16px; // Gap between buttons in ButtonGroup (consistent across all sizes)
// Grid gutter - consistent with PageGrid
$bds-grid-gutter: 8px;
@@ -66,18 +63,6 @@ $bds-grid-gutter: 8px;
// =============================================================================
// Base Styles
// =============================================================================
.bds-feature-two-column__button-group {
.bds-btn--tertiary {
padding-top: 0px !important;
padding-bottom: 0px !important;
}
}
.bds-feature-two-column__cta-row{
.bds-btn--tertiary {
padding-top: 16px !important;
}
}
.bds-feature-two-column {
width: 100%;
@@ -230,11 +215,14 @@ $bds-grid-gutter: 8px;
&__content--multiple {
// Mobile: no gap since we only have 2 items (text-group and button-group)
// The 24px gap is handled via button-group margin or flex gap
gap: 0;
gap: 24px;
justify-content: flex-start;
@include media-breakpoint-up(md) {
gap: 0;
}
@include media-breakpoint-up(lg) {
gap: 0; // Desktop uses space-between for auto distribution
justify-content: space-between;
}
@@ -244,39 +232,6 @@ $bds-grid-gutter: 8px;
}
}
// Button group - contains all buttons in the multiple links layout
// 16px gap between buttons on ALL screen sizes
&__button-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $bds-feature-mobile-button-gap; // 16px on mobile
margin-top: $bds-feature-mobile-content-gap; // 24px spacing from text-group on mobile
@include media-breakpoint-up(md) {
gap: $bds-feature-tablet-button-gap; // 16px on tablet
margin-top: $bds-feature-tablet-content-gap; // 32px spacing from text-group on tablet
}
@include media-breakpoint-up(lg) {
gap: $bds-feature-desktop-button-gap; // 16px on desktop
margin-top: 0; // Desktop uses space-between, no explicit margin needed
}
// Tertiary links need left padding removed to align text with title
>.bds-btn--tertiary {
padding-left: 0 !important;
margin-left: 0;
&:hover:not(:disabled):not(.bds-btn--disabled),
&:focus:not(:disabled):not(.bds-btn--disabled),
&:focus-visible:not(:disabled):not(.bds-btn--disabled),
&:active:not(:disabled):not(.bds-btn--disabled) {
padding-left: 0 !important;
}
}
}
// Text group - title + description
&__text-group {
display: flex;
@@ -308,69 +263,7 @@ $bds-grid-gutter: 8px;
margin: 0;
}
// CTA container - base styles
&__cta {
display: flex;
flex-direction: column;
align-items: flex-start;
@include media-breakpoint-up(md) {
flex-direction: row;
flex-wrap: wrap;
}
}
// CTA variant: Single secondary button - maintains vertical layout
&__cta--single {
flex-direction: column;
}
// CTA variant: Primary + tertiary - horizontal on tablet+
&__cta--double {
@include media-breakpoint-up(md) {
flex-direction: row;
align-items: center;
}
}
// CTA row - for first two buttons in multiple links layout (Primary + Tertiary)
// Horizontal layout on tablet+ (md and lg share same styles)
&__cta-row {
display: flex;
flex-direction: column;
align-items: flex-start;
@include media-breakpoint-up(md) {
flex-direction: row;
align-items: center;
}
@include media-breakpoint-up(lg) {
flex-direction: row;
align-items: center;
}
}
// Tertiary group - groups remaining tertiary links together as a single unit
// No gap between tertiary links - they stack tightly as a group
&__tertiary-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0; // No spacing between tertiary links in the group
// Tertiary links need left padding removed to align text with title
.bds-btn--tertiary {
padding-left: 0 !important;
margin-left: 0;
&:hover:not(:disabled):not(.bds-btn--disabled),
&:focus:not(:disabled):not(.bds-btn--disabled),
&:focus-visible:not(:disabled):not(.bds-btn--disabled),
&:active:not(:disabled):not(.bds-btn--disabled) {
padding-left: 0 !important;
}
}
}
// Media container
&__media {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import clsx from 'clsx';
import { Button } from '../../components/Button/Button';
import { PageGrid } from '../../components/PageGrid/page-grid';
import { ButtonGroup, ButtonConfig, validateButtonGroup } from '../ButtonGroup/ButtonGroup';
export interface FeatureTwoColumnLink {
/** Link label text */
@@ -59,6 +59,26 @@ export const FeatureTwoColumn: React.FC<FeatureTwoColumnProps> = ({
media,
className,
}) => {
// Determine button color based on background
// Rule: Black buttons must be used for all backgrounds (including neutral)
const buttonColor = 'black';
const forceColor = true;
// Convert links to ButtonConfig format
const buttonConfigs: ButtonConfig[] = links.map(link => ({
label: link.label,
href: link.href,
forceColor: forceColor,
}));
// Validate buttons (FeatureTwoColumn supports 1-5 links per design spec)
const buttonValidation = validateButtonGroup(buttonConfigs, 5);
// Log warnings in development mode
if (process.env.NODE_ENV === 'development' && buttonValidation.warnings.length > 0) {
buttonValidation.warnings.forEach(warning => console.warn(warning));
}
// Build root class names
const rootClasses = clsx(
'bds-feature-two-column',
@@ -67,94 +87,30 @@ export const FeatureTwoColumn: React.FC<FeatureTwoColumnProps> = ({
className
);
// Determine button color based on background
// Rule: Black buttons must be used for all backgrounds (including neutral)
const buttonColor = 'black';
const forceColor = true;
// Render content section with appropriate CTA layout based on link count
// For 3-5 links, items are direct children for space-between distribution
// Render content section with ButtonGroup
const renderContent = () => {
const linkCount = links.length;
// Determine content class based on validated button count
const contentClass = clsx(
'bds-feature-two-column__content',
{
'bds-feature-two-column__content--multiple': buttonValidation.buttons.length >= 3,
}
);
// 1 link: Secondary button
if (linkCount === 1) {
return (
<div className="bds-feature-two-column__content">
<div className="bds-feature-two-column__text-group">
<h2 className="bds-feature-two-column__title">{title}</h2>
<p className="bds-feature-two-column__description">{description}</p>
</div>
<div className="bds-feature-two-column__cta bds-feature-two-column__cta--single">
<Button variant="secondary" color={buttonColor} forceColor={forceColor} href={links[0].href}>
{links[0].label}
</Button>
</div>
</div>
);
}
// 2 links: Primary + Tertiary in a row
if (linkCount === 2) {
return (
<div className="bds-feature-two-column__content">
<div className="bds-feature-two-column__text-group">
<h2 className="bds-feature-two-column__title">{title}</h2>
<p className="bds-feature-two-column__description">{description}</p>
</div>
<div className="bds-feature-two-column__cta bds-feature-two-column__cta--double">
<Button variant="primary" color={buttonColor} forceColor={forceColor} href={links[0].href}>
{links[0].label}
</Button>
<Button variant="tertiary" color={buttonColor} forceColor={forceColor} href={links[1].href}>
{links[1].label}
</Button>
</div>
</div>
);
}
// 3-5 links: Text group + Button group (contains all buttons with consistent 16px spacing)
// Desktop: space-between distribution between text-group and button-group
// Tablet: 32px gap, Mobile: 24px gap
return (
<div className="bds-feature-two-column__content bds-feature-two-column__content--multiple">
<div className={contentClass}>
<div className="bds-feature-two-column__text-group">
<h2 className="bds-feature-two-column__title">{title}</h2>
<p className="bds-feature-two-column__description">{description}</p>
</div>
{/* Button group - all buttons grouped with 16px spacing between them */}
<div className="bds-feature-two-column__button-group">
{/* First two links in a row: Primary + Tertiary */}
<div className="bds-feature-two-column__cta-row">
<Button variant="primary" color={buttonColor} forceColor={forceColor} href={links[0].href}>
{links[0].label}
</Button>
{links[1] && (
<Button variant="tertiary" color={buttonColor} forceColor={forceColor} href={links[1].href}>
{links[1].label}
</Button>
)}
</div>
{/* Secondary button */}
{links[2] && (
<Button variant="secondary" color={buttonColor} forceColor={forceColor} href={links[2].href}>
{links[2].label}
</Button>
)}
{/* Remaining tertiary links */}
{links.slice(3).map((link) => (
<Button
key={`${link.href}-${link.label}`}
variant="tertiary"
color={buttonColor}
forceColor={forceColor}
href={link.href}
>
{link.label}
</Button>
))}
</div>
{buttonValidation.isValid && (
<ButtonGroup
buttons={buttonValidation.buttons}
color={buttonColor}
forceColor={forceColor}
singleButtonVariant="secondary"
/>
)}
</div>
);
};

View File

@@ -0,0 +1,124 @@
import React, { forwardRef, useCallback } from "react";
import clsx from "clsx";
import { PageGrid } from "shared/components/PageGrid/page-grid";
import { ButtonGroup, ButtonConfig, validateButtonGroup } from "shared/patterns/ButtonGroup/ButtonGroup";
import { isEmpty, isEnvironment } from "shared/utils";
import {
DesignConstrainedCallToActionsProps,
DesignConstrainedVideoProps,
} from "shared/utils/types";
export interface FeaturedVideoHeroProps
extends
React.ComponentPropsWithoutRef<"header">,
DesignConstrainedCallToActionsProps {
headline: React.ReactNode;
subtitle?: React.ReactNode;
videoElement: DesignConstrainedVideoProps;
}
const FeaturedVideoHero = forwardRef<HTMLElement, FeaturedVideoHeroProps>(
(props, ref) => {
const {
headline,
subtitle,
videoElement,
callsToAction,
className,
...rest
} = props;
const validateProps = useCallback<() => boolean>(() => {
const requiredProps = { headline, videoElement } as const;
let isValid = true;
for (const [key, value] of Object.entries(requiredProps)) {
if (isEmpty(value)) {
if (isEnvironment(["development", "test"])) {
console.warn(`${key} is required for FeaturedVideoHero`);
}
isValid = false;
}
}
return isValid;
}, [headline, videoElement]);
if (!validateProps()) {
return null;
}
// Convert callsToAction to ButtonConfig format for ButtonGroup
const buttonConfigs: ButtonConfig[] = (callsToAction ?? [])
.filter((cta) => !isEmpty(cta))
.map((cta) => ({
label: typeof cta?.children === 'string' ? cta.children : '',
href: cta?.href,
onClick: cta?.onClick,
forceColor: true,
}));
// Validate buttons (max 2 CTAs supported)
const buttonValidation = validateButtonGroup(buttonConfigs, 2);
// Log warnings in development mode
if (isEnvironment(["development", "test"]) && buttonValidation.warnings.length > 0) {
buttonValidation.warnings.forEach(warning => console.warn(warning));
}
const hasCallsToAction = buttonValidation.isValid && buttonValidation.buttons.length > 0;
return (
<header
ref={ref}
className={clsx("bds-featured-video-hero", className)}
{...rest}
>
<PageGrid>
<PageGrid.Row>
<PageGrid.Col span={{ base: 4, md: 8, lg: 6 }}>
<div className="bds-featured-video-hero__content">
<h1 className="mb-0 h-md">
{headline}
</h1>
<div className="bds-featured-video-hero__bottom-group">
{subtitle && (
<PageGrid.Row className="bds-featured-video-hero__subtitle body-l">
<PageGrid.Col
span={{ base: "fill", md: 6, lg: 10 }}
className="bds-featured-video-hero__subtitle-col"
>
{subtitle}
</PageGrid.Col>
</PageGrid.Row>
)}
{hasCallsToAction && (
<ButtonGroup
buttons={buttonValidation.buttons}
color="green"
forceColor
gap="small"
/>
)}
</div>
</div>
</PageGrid.Col>
<PageGrid.Col
span={{ base: 4, md: 8, lg: 6 }}
>
<div className="bds-featured-video-hero__video-container">
<video
{...videoElement}
className="bds-featured-video-hero__video"
/>
</div>
</PageGrid.Col>
</PageGrid.Row>
</PageGrid>
</header>
);
},
);
FeaturedVideoHero.displayName = "FeaturedVideoHero";
export default FeaturedVideoHero;

View File

@@ -0,0 +1,162 @@
# FeaturedVideoHero Pattern
A page-level hero pattern featuring a headline, optional subtitle, call-to-action buttons, and a featured video. The video uses native HTML `<video>` props and is displayed in a responsive two-column layout with content on the left and video on the right.
## Overview
The FeaturedVideoHero component provides a structured hero section with:
- Responsive two-column layout (content left, video right) that stacks on smaller screens
- Required headline and video; optional subtitle and call-to-action buttons
- Design-constrained CTAs: primary and optional secondary, with variant and color set by the component
- Development-time validation: returns `null` when required props are missing and logs warnings in development/test
## Basic Usage
```tsx
import { FeaturedVideoHero } from "shared/patterns/FeaturedVideoHero";
function MyPage() {
return (
<FeaturedVideoHero
headline="Build on XRPL"
subtitle={
<p>
Issue, manage, and trade real-world assets without needing to build
smart contracts.
</p>
}
callsToAction={[{ children: "Get Started", href: "/docs" }]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
);
}
```
## Props
| Prop | Type | Required | Description |
| --------------- | --------------------------------- | -------- | ---------------------------------------------------------------------------- |
| `headline` | `React.ReactNode` | Yes | Hero headline text (h-md typography) |
| `subtitle` | `React.ReactNode` | No | Hero subtitle content |
| `callsToAction` | `DesignConstrainedCallsToActions` | No | Array with primary CTA and optional secondary CTA. Omit to hide CTA section. |
| `videoElement` | `DesignConstrainedVideoProps` | Yes | Native `<video>` element props (e.g. `src`, `autoPlay`, `loop`, `muted`) |
| `className` | `string` | No | Additional CSS classes for the header element |
| `...rest` | `HTMLHeaderElement` attributes | No | Any other HTML header attributes |
### Calls to Action
The `callsToAction` prop is optional. When provided, at least one non-empty CTA is required to show the CTA section. The component uses design-constrained Button props; `variant` and `color` are set automatically:
- **Primary CTA**: `variant="primary"`, `color="green"`, `forceColor={true}`
- **Secondary CTA**: `variant="tertiary"`, `color="green"`, `forceColor={true}`
All other Button props are supported (e.g., `children`, `href`, `onClick`). Do not pass `variant` or `color` in the CTA objects.
### Video Element
`videoElement` accepts native HTML video element props. Required and commonly used props:
- `src` (required) Video URL
- `autoPlay`, `loop`, `muted`, `playsInline` Typical for background/hero autoplay
- `controls`, `preload`, `poster` Optional; use for user-controlled playback
The video is rendered with `object-fit: cover` and a 16:9 aspect ratio container.
## Examples
### With primary and secondary CTAs
```tsx
<FeaturedVideoHero
headline="Real-world asset tokenization"
subtitle="Learn how to issue crypto tokens and build tokenization solutions."
callsToAction={[
{ children: "Get Started", href: "/docs" },
{ children: "Learn More", href: "/about" },
]}
videoElement={{
src: "/video/tokenization.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
```
### Without subtitle
```tsx
<FeaturedVideoHero
headline="Headline Only"
callsToAction={[{ children: "Get Started", href: "/docs" }]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
```
### With video controls
```tsx
<FeaturedVideoHero
headline="Watch and Learn"
subtitle="Explore our video tutorials and guides."
callsToAction={[{ children: "Watch Tutorials", href: "/tutorials" }]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: false,
loop: true,
muted: true,
playsInline: true,
controls: true,
preload: "metadata",
}}
/>
```
## Validation
- **Required props**: `headline`, `videoElement`. If either is missing or empty, the component returns `null` and (in development/test) logs a console warning.
- **Optional props**: `subtitle`, `callsToAction`. Omit `callsToAction` or pass an array with no renderable CTAs to hide the CTA section.
## Responsive Behavior
- **Mobile / small screens**: Content and video stack vertically; video appears below the content block with top margin.
- **Large (lg+)**: Two-column layout: content (5 cols) on the left, video (6 cols, offset 1) on the right. Video container uses 16:9 aspect ratio and `object-fit: cover`.
## CSS Classes
- `bds-featured-video-hero` Root header element
- `bds-featured-video-hero__content` Content column (headline, subtitle, CTAs)
- `bds-featured-video-hero__title` Headline (`h1`)
- `bds-featured-video-hero__subtitle` Subtitle row
- `bds-featured-video-hero__subtitle-col` Subtitle column
- `bds-featured-video-hero__cta-buttons` CTA buttons wrapper
- `bds-featured-video-hero__video-container` Video wrapper (16:9)
- `bds-featured-video-hero__video` Video element
## Best Practices
1. **Video format**: Use MP4 with H.264 for broad compatibility; keep file sizes reasonable for fast loading.
2. **Autoplay**: Use `muted` and `playsInline` with `autoPlay` for reliable autoplay on mobile.
3. **CTAs**: Keep CTA text concise and action-oriented; primary CTA should be the main action.
4. **Headlines**: Keep headlines concise; use the subtitle for additional context.
5. **Accessibility**: Provide an `aria-label` (or other accessible name) on the video when it conveys meaningful content.
## Showcase
An interactive showcase with more examples and prop documentation is available at:
- **Showcase page**: `/about/featured-video-hero-showcase.page.tsx`

View File

@@ -0,0 +1,88 @@
.bds-featured-video-hero{
padding: 24px 0;
@include media-breakpoint-up(md) {
padding: 32px 0;
}
@include media-breakpoint-up(lg) {
padding: 40px 0;
}
@include bds-theme-mode(light) {
background-color: $white;
}
@include bds-theme-mode(dark) {
background-color: $black;
}
&__content {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
@include media-breakpoint-up(md) {
gap: 32px;
}
@include media-breakpoint-up(lg) {
gap: 40px;
}
}
&__subtitle {
width: 100%;
margin: 0;
padding: 0;
}
&__video-container {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
overflow: hidden;
margin-top: 16px;
@include media-breakpoint-up(md) {
margin-top: 24px;
}
@include media-breakpoint-up(lg) {
margin-top: 0px;
}
}
&__video {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: inline-block;
}
&__bottom-group {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-wrap: wrap;
gap: 24px;
margin-top: auto;
width: 100%;
@include media-breakpoint-up(md) {
flex-direction: row !important;
align-items: center;
gap: 32px;
}
@include media-breakpoint-up(lg) {
gap: 40px;
}
p:last-child {
margin-bottom: 0;
}
}
}

View File

@@ -1,48 +1,30 @@
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 { Button } from "shared/components/Button/Button";
import {
isEmpty,
DesignConstrainedButtonProps,
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;
};
import {
DesignConstrainedImageProps,
DesignConstrainedVideoProps,
} from "shared/utils/types";
/**
* 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
};
} & DesignConstrainedImageProps;
/**
* 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
};
} & DesignConstrainedVideoProps;
/**
* Custom element media type - allows passing any React element
@@ -95,6 +77,7 @@ const MediaRenderer: React.FC<{ media: HeaderHeroMedia }> = memo(
}
case "video": {
// alt here is being used as a aria label value
const { type, alt, ...videoProps } = media;
return (
<div className={mediaContainerClassName}>

View File

@@ -108,7 +108,6 @@ export const LogoRectangleGrid: React.FC<LogoRectangleGridProps> = ({
heading,
description,
logos,
className = '',
}) => {
// Build class names using BEM with bds namespace
const classNames = clsx(

View File

@@ -40,19 +40,6 @@ $bds-lsg-text-gap-desktop: 16px;
@extend .d-flex;
@extend .flex-column;
@extend .w-100;
// Mobile-first gap
gap: $bds-lsg-header-gap-mobile;
// Tablet breakpoint
@include media-breakpoint-up(md) {
gap: $bds-lsg-header-gap-tablet;
}
// Desktop breakpoint
@include media-breakpoint-up(lg) {
gap: $bds-lsg-header-gap-desktop;
}
}
// =============================================================================

View File

@@ -2,7 +2,7 @@ import React from 'react';
import clsx from 'clsx';
import { PageGrid, PageGridCol, PageGridRow } from 'shared/components/PageGrid/page-grid';
import { TileLogo, TileLogoProps } from '../../components/TileLogo/TileLogo';
import { ButtonGroup } from '../ButtonGroup/ButtonGroup';
import { ButtonGroup, ButtonConfig, validateButtonGroup } from '../ButtonGroup/ButtonGroup';
export interface LogoItem extends TileLogoProps {}
@@ -13,18 +13,8 @@ export interface LogoSquareGridProps {
heading?: string;
/** Optional description text */
description?: string;
/** Primary button configuration */
primaryButton?: {
label: string;
href?: string;
onClick?: () => void;
};
/** Tertiary button configuration */
tertiaryButton?: {
label: string;
href?: string;
onClick?: () => void;
};
/** Button configurations (1-2 buttons supported) */
buttons?: ButtonConfig[];
/** Array of logo items to display in the grid */
logos: LogoItem[];
/** Additional CSS classes */
@@ -55,8 +45,10 @@ export interface LogoSquareGridProps {
* variant="green"
* heading="Our Partners"
* description="Leading companies building on XRPL."
* primaryButton={{ label: "View All Partners", href: "/partners" }}
* tertiaryButton={{ label: "Become a Partner", href: "/partner-program" }}
* buttons={[
* { label: "View All Partners", href: "/partners" },
* { label: "Become a Partner", href: "/partner-program" }
* ]}
* logos={[
* { src: "/logos/partner1.svg", alt: "Partner 1", href: "https://partner1.com" }
* ]}
@@ -66,11 +58,18 @@ export const LogoSquareGrid: React.FC<LogoSquareGridProps> = ({
variant = 'gray',
heading,
description,
primaryButton,
tertiaryButton,
buttons,
logos,
className = '',
}) => {
// Validate buttons if provided (max 2 buttons supported)
const buttonValidation = buttons ? validateButtonGroup(buttons, 2) : null;
// Log warnings in development mode
if (process.env.NODE_ENV === 'development' && buttonValidation?.warnings.length) {
buttonValidation.warnings.forEach(warning => console.warn(warning));
}
// Build class names using BEM with bds namespace
const classNames = clsx(
'bds-logo-square-grid',
@@ -79,7 +78,8 @@ export const LogoSquareGrid: React.FC<LogoSquareGridProps> = ({
);
// Determine if we should show the header section
const hasHeader = !!(heading || description || primaryButton || tertiaryButton);
const hasButtons = buttonValidation?.isValid && buttonValidation.buttons.length > 0;
const hasHeader = !!(heading || description || hasButtons);
return (
<PageGrid className={classNames}>
@@ -97,12 +97,13 @@ export const LogoSquareGrid: React.FC<LogoSquareGridProps> = ({
)}
{/* Buttons */}
<ButtonGroup
primaryButton={primaryButton}
tertiaryButton={tertiaryButton}
color="green"
gap="small"
/>
{hasButtons && (
<ButtonGroup
buttons={buttonValidation.buttons}
color="green"
gap="small"
/>
)}
</div>
)}
</PageGridCol>

View File

@@ -1,4 +1,4 @@
import { ButtonProps } from '../components/Button/Button';
import { ButtonProps } from "../components/Button/Button";
/**
* Button props with design constraints applied.
@@ -12,4 +12,42 @@ import { ButtonProps } from '../components/Button/Button';
* onClick: () => console.log('clicked')
* };
*/
export type DesignConstrainedButtonProps = Omit<ButtonProps, 'variant' | 'color'>;
export type DesignConstrainedButtonProps = Omit<
ButtonProps,
"variant" | "color"
>;
export type DesignConstrainedCallsToActions = [
DesignConstrainedButtonProps,
DesignConstrainedButtonProps?,
];
export type DesignConstrainedCallToActionsProps = {
callsToAction?: DesignConstrainedCallsToActions;
};
/**
* 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.
*/
export type MediaStyleProps = {
className?: string;
style?: React.CSSProperties;
};
type ModifiedMediaProps = {
alt?: string;
};
export type DesignConstrainedVideoProps = Omit<
React.ComponentPropsWithRef<"video">,
keyof MediaStyleProps
> &
// the "alt" value here will be used with an aria label
ModifiedMediaProps;
export type DesignConstrainedImageProps = Omit<
React.ComponentPropsWithRef<"img">,
keyof MediaStyleProps
>;

View File

@@ -5770,7 +5770,7 @@ textarea.form-control-lg {
justify-content: space-evenly !important;
}
.align-items-start, .bds-button-group {
.align-items-start {
align-items: flex-start !important;
}
@@ -13242,6 +13242,22 @@ html.dark .bds-btn--tertiary:not(.bds-btn--black):disabled, html.dark .bds-btn--
}
}
.bds-btn--no-padding {
padding: 0 !important;
justify-content: flex-start !important;
}
.bds-btn--no-padding:hover:not(:disabled):not(.bds-btn--disabled), .bds-btn--no-padding:focus-visible:not(:disabled):not(.bds-btn--disabled), .bds-btn--no-padding:active:not(:disabled):not(.bds-btn--disabled) {
padding: 0 !important;
}
@media (max-width: 1279.98px) {
.bds-btn--no-padding {
padding: 0 !important;
}
.bds-btn--no-padding:hover:not(:disabled):not(.bds-btn--disabled), .bds-btn--no-padding:focus-visible:not(:disabled):not(.bds-btn--disabled), .bds-btn--no-padding:active:not(:disabled):not(.bds-btn--disabled) {
padding: 0 !important;
}
}
.bds-carousel-button {
appearance: none;
border: none;
@@ -20030,6 +20046,95 @@ html.dark .bds-text-card--disabled .bds-text-card__overlay {
}
}
.bds-featured-video-hero {
padding: 24px 0;
}
@media (min-width: 576px) {
.bds-featured-video-hero {
padding: 32px 0;
}
}
@media (min-width: 992px) {
.bds-featured-video-hero {
padding: 40px 0;
}
}
html.light .bds-featured-video-hero {
background-color: #FFFFFF;
}
html.dark .bds-featured-video-hero {
background-color: #141414;
}
.bds-featured-video-hero__content {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
}
@media (min-width: 576px) {
.bds-featured-video-hero__content {
gap: 32px;
}
}
@media (min-width: 992px) {
.bds-featured-video-hero__content {
gap: 40px;
}
}
.bds-featured-video-hero__subtitle {
width: 100%;
margin: 0;
padding: 0;
}
.bds-featured-video-hero__video-container {
width: 100%;
height: auto;
aspect-ratio: 16/9;
overflow: hidden;
margin-top: 16px;
}
@media (min-width: 576px) {
.bds-featured-video-hero__video-container {
margin-top: 24px;
}
}
@media (min-width: 992px) {
.bds-featured-video-hero__video-container {
margin-top: 0px;
}
}
.bds-featured-video-hero__video {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: inline-block;
}
.bds-featured-video-hero__bottom-group {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-wrap: wrap;
gap: 24px;
margin-top: auto;
width: 100%;
}
@media (min-width: 576px) {
.bds-featured-video-hero__bottom-group {
flex-direction: row !important;
align-items: center;
gap: 32px;
}
}
@media (min-width: 992px) {
.bds-featured-video-hero__bottom-group {
gap: 40px;
}
}
.bds-featured-video-hero__bottom-group p:last-child {
margin-bottom: 0;
}
.bds-header-hero-primary-media {
padding-top: 24px;
padding-bottom: 24px;
@@ -20438,6 +20543,7 @@ html.light .bds-hero-split-media--accent .bds-hero-split-media__subtitle {
}
.bds-button-group {
align-items: start;
gap: 8px;
}
@media (min-width: 576px) {
@@ -20459,6 +20565,14 @@ html.light .bds-hero-split-media--accent .bds-hero-split-media__subtitle {
}
}
.bds-button-group--block {
flex-direction: column !important;
gap: 16px !important;
}
.bds-button-group--block .bds-btn {
width: 100%;
}
.bds-callout-media-banner {
box-sizing: border-box;
min-height: 280px;
@@ -20771,42 +20885,50 @@ html.dark .bds-card-stats__description {
gap: 8px;
max-width: 100%;
}
@media (min-width: 576px) {
.bds-carousel-card-list__header-content {
gap: 8px;
}
}
@media (min-width: 992px) {
.bds-carousel-card-list__header-content {
gap: 16px;
max-width: 808px;
}
}
.bds-carousel-card-list__heading {
margin: 0;
}
.bds-carousel-card-list__description {
margin: 0;
}
.bds-carousel-card-list__nav {
display: flex;
gap: 8px;
justify-content: flex-end;
flex-shrink: 0;
margin-top: 24px;
}
@media (min-width: 576px) {
.bds-carousel-card-list__nav {
margin-top: 32px;
}
}
@media (min-width: 992px) {
.bds-carousel-card-list__nav {
margin-top: 40px;
}
padding: 4px;
margin: -4px;
}
.bds-carousel-card-list__track-wrapper {
margin-top: 24px;
overflow: visible;
padding-left: 18px;
}
@media (min-width: 576px) {
.bds-carousel-card-list__track-wrapper {
margin-top: 32px;
padding-left: 24px;
}
}
@media (min-width: 992px) {
.bds-carousel-card-list__track-wrapper {
margin-top: 40px;
padding-left: 32px;
}
}
@@ -20883,20 +21005,6 @@ html.dark .bds-card-stats__description {
color: #FFFFFF;
}
.bds-carousel-card-list__grid {
gap: 24px;
}
@media (min-width: 576px) {
.bds-carousel-card-list__grid {
gap: 32px;
}
}
@media (min-width: 992px) {
.bds-carousel-card-list__grid {
gap: 40px;
}
}
html.light .bds-carousel-card-list__track:focus-visible {
outline-color: #111112;
}
@@ -21262,20 +21370,6 @@ html.light .bds-carousel-featured--bg-yellow .bds-divider {
background-color: #141414;
}
.bds-logo-square-grid {
gap: 24px;
}
@media (min-width: 576px) {
.bds-logo-square-grid {
gap: 32px;
}
}
@media (min-width: 992px) {
.bds-logo-square-grid {
gap: 40px;
}
}
.bds-logo-square-grid__header {
margin-top: 24px;
margin-bottom: 24px;
@@ -21441,15 +21535,6 @@ html.dark .bds-cards-featured__description {
}
}
.bds-feature-two-column__button-group .bds-btn--tertiary {
padding-top: 0px !important;
padding-bottom: 0px !important;
}
.bds-feature-two-column__cta-row .bds-btn--tertiary {
padding-top: 16px !important;
}
.bds-feature-two-column {
width: 100%;
}
@@ -21569,44 +21654,22 @@ html.dark .bds-cards-featured__description {
}
}
.bds-feature-two-column__content--multiple {
gap: 0;
gap: 24px;
justify-content: flex-start;
}
@media (min-width: 576px) {
.bds-feature-two-column__content--multiple {
gap: 0;
}
}
@media (min-width: 992px) {
.bds-feature-two-column__content--multiple {
gap: 0;
justify-content: space-between;
}
}
.bds-feature-two-column__content--multiple > .bds-btn--secondary {
align-self: flex-start;
}
.bds-feature-two-column__button-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
margin-top: 24px;
}
@media (min-width: 576px) {
.bds-feature-two-column__button-group {
gap: 16px;
margin-top: 32px;
}
}
@media (min-width: 992px) {
.bds-feature-two-column__button-group {
gap: 16px;
margin-top: 0;
}
}
.bds-feature-two-column__button-group > .bds-btn--tertiary {
padding-left: 0 !important;
margin-left: 0;
}
.bds-feature-two-column__button-group > .bds-btn--tertiary:hover:not(:disabled):not(.bds-btn--disabled), .bds-feature-two-column__button-group > .bds-btn--tertiary:focus:not(:disabled):not(.bds-btn--disabled), .bds-feature-two-column__button-group > .bds-btn--tertiary:focus-visible:not(:disabled):not(.bds-btn--disabled), .bds-feature-two-column__button-group > .bds-btn--tertiary:active:not(:disabled):not(.bds-btn--disabled) {
padding-left: 0 !important;
}
.bds-feature-two-column__text-group {
display: flex;
flex-direction: column;
@@ -21678,56 +21741,6 @@ html.dark .bds-cards-featured__description {
color: #141414;
margin: 0;
}
.bds-feature-two-column__cta {
display: flex;
flex-direction: column;
align-items: flex-start;
}
@media (min-width: 576px) {
.bds-feature-two-column__cta {
flex-direction: row;
flex-wrap: wrap;
}
}
.bds-feature-two-column__cta--single {
flex-direction: column;
}
@media (min-width: 576px) {
.bds-feature-two-column__cta--double {
flex-direction: row;
align-items: center;
}
}
.bds-feature-two-column__cta-row {
display: flex;
flex-direction: column;
align-items: flex-start;
}
@media (min-width: 576px) {
.bds-feature-two-column__cta-row {
flex-direction: row;
align-items: center;
}
}
@media (min-width: 992px) {
.bds-feature-two-column__cta-row {
flex-direction: row;
align-items: center;
}
}
.bds-feature-two-column__tertiary-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0;
}
.bds-feature-two-column__tertiary-group .bds-btn--tertiary {
padding-left: 0 !important;
margin-left: 0;
}
.bds-feature-two-column__tertiary-group .bds-btn--tertiary:hover:not(:disabled):not(.bds-btn--disabled), .bds-feature-two-column__tertiary-group .bds-btn--tertiary:focus:not(:disabled):not(.bds-btn--disabled), .bds-feature-two-column__tertiary-group .bds-btn--tertiary:focus-visible:not(:disabled):not(.bds-btn--disabled), .bds-feature-two-column__tertiary-group .bds-btn--tertiary:active:not(:disabled):not(.bds-btn--disabled) {
padding-left: 0 !important;
}
.bds-feature-two-column__media {
width: 100%;
overflow: hidden;

View File

@@ -102,6 +102,7 @@ $line-height-base: 1.5;
@import "../shared/components/TextCard/TextCard.scss";
@import "../shared/components/SmallTilesSection/_small-tiles-section.scss";
@import "../shared/components/StandardCard/_standard-card.scss";
@import "../shared/patterns/FeaturedVideoHero/_featured-video-hero.scss";
@import "../shared/patterns/HeaderHeroPrimaryMedia/_header-hero-primary-media.scss";
@import "../shared/patterns/HeaderHeroSplitMedia/HeaderHeroSplitMedia.scss";
@import "../shared/patterns/ButtonGroup/ButtonGroup.scss";