Finish migrating components

This commit is contained in:
adilallo
2026-01-29 17:59:11 -07:00
parent b5735bb2ad
commit 539f6c62e3
79 changed files with 2449 additions and 1730 deletions
@@ -1,36 +1,15 @@
"use client"; "use client";
import { memo } from "react"; import { memo } from "react";
import { useComponentId } from "../hooks"; import { useComponentId } from "../../hooks";
import { CheckboxView } from "./Checkbox.view";
import type { CheckboxProps } from "./Checkbox.types";
interface CheckboxProps { const CheckboxContainer = memo<CheckboxProps>(
checked?: boolean;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
label?: string;
className?: string;
onChange?: (_data: {
checked: boolean;
value?: string;
event: React.MouseEvent | React.KeyboardEvent;
}) => void;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
}
/**
* Checkbox
* A basic controlled checkbox with visual modes and interaction states.
* This is a minimal first pass; visuals will be refined collaboratively.
*/
const Checkbox = memo<CheckboxProps>(
({ ({
checked = false, checked = false,
mode = "standard", // "standard" | "inverse" mode = "standard",
state = "default", // "default" | "hover" | "focus" state = "default",
disabled = false, disabled = false,
label, label,
className = "", className = "",
@@ -109,72 +88,41 @@ const Checkbox = memo<CheckboxProps>(
...props, ...props,
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleToggle(e);
}
};
return ( return (
<label <CheckboxView
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${ checkboxId={checkboxId}
disabled ? "opacity-60 cursor-not-allowed" : "" labelId={labelId}
} ${className}`} checked={checked}
onMouseDown={(e) => e.preventDefault()} mode={mode}
> state={state}
<span disabled={disabled}
{...accessibilityProps} label={label}
onClick={handleToggle} name={name}
onKeyDown={(e) => { value={value}
if (e.key === " " || e.key === "Enter") { ariaLabel={ariaLabel}
e.preventDefault(); className={className}
handleToggle(e); combinedBoxStyles={combinedBoxStyles}
} defaultOutlineClass={defaultOutlineClass}
}} conditionalHoverOutlineClass={conditionalHoverOutlineClass}
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`} conditionalFocusClass={conditionalFocusClass}
style={{ backgroundWhenChecked={backgroundWhenChecked}
backgroundColor: backgroundWhenChecked, checkGlyphColor={checkGlyphColor}
}} labelColor={labelColor}
> accessibilityProps={accessibilityProps}
{/* Simple check glyph */} onToggle={handleToggle}
<svg onKeyDown={handleKeyDown}
width="16" />
height="16"
viewBox="0 0 12 12"
aria-hidden="true"
focusable="false"
>
<polyline
points="2.5 6 5 8.5 10 3.5"
stroke={checkGlyphColor}
strokeWidth="1.25"
fill="none"
strokeLinecap="square"
strokeLinejoin="miter"
vectorEffect="non-scaling-stroke"
/>
</svg>
</span>
{label && (
<span
id={labelId}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
{label}
</span>
)}
{/* Hidden native input for form compatibility (optional for now) */}
<input
type="checkbox"
name={name}
value={value}
checked={checked}
onChange={() => {}}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
readOnly
/>
</label>
); );
}, },
); );
Checkbox.displayName = "Checkbox"; CheckboxContainer.displayName = "Checkbox";
export default Checkbox; export default CheckboxContainer;
+41
View File
@@ -0,0 +1,41 @@
export interface CheckboxProps {
checked?: boolean;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
label?: string;
className?: string;
onChange?: (_data: {
checked: boolean;
value?: string;
event: React.MouseEvent | React.KeyboardEvent;
}) => void;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
}
export interface CheckboxViewProps {
checkboxId: string;
labelId: string;
checked: boolean;
mode: "standard" | "inverse";
state: "default" | "hover" | "focus";
disabled: boolean;
label?: string;
name?: string;
value?: string;
ariaLabel?: string;
className: string;
combinedBoxStyles: string;
defaultOutlineClass: string;
conditionalHoverOutlineClass: string;
conditionalFocusClass: string;
backgroundWhenChecked: string;
checkGlyphColor: string;
labelColor: string;
accessibilityProps: React.HTMLAttributes<HTMLSpanElement>;
onToggle: (e: React.MouseEvent | React.KeyboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLSpanElement>) => void;
}
+82
View File
@@ -0,0 +1,82 @@
import type { CheckboxViewProps } from "./Checkbox.types";
export function CheckboxView({
checkboxId,
labelId,
checked,
disabled,
label,
name,
value,
ariaLabel,
className,
combinedBoxStyles,
defaultOutlineClass,
conditionalHoverOutlineClass,
conditionalFocusClass,
backgroundWhenChecked,
checkGlyphColor,
labelColor,
accessibilityProps,
onToggle,
onKeyDown,
}: CheckboxViewProps) {
return (
<label
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
disabled ? "opacity-60 cursor-not-allowed" : ""
} ${className}`}
onMouseDown={(e) => e.preventDefault()}
>
<span
{...accessibilityProps}
onClick={onToggle}
onKeyDown={onKeyDown}
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
style={{
backgroundColor: backgroundWhenChecked,
}}
>
{/* Simple check glyph */}
<svg
width="16"
height="16"
viewBox="0 0 12 12"
aria-hidden="true"
focusable="false"
>
<polyline
points="2.5 6 5 8.5 10 3.5"
stroke={checkGlyphColor}
strokeWidth="1.25"
fill="none"
strokeLinecap="square"
strokeLinejoin="miter"
vectorEffect="non-scaling-stroke"
/>
</svg>
</span>
{label && (
<span
id={labelId}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
{label}
</span>
)}
{/* Hidden native input for form compatibility (optional for now) */}
<input
type="checkbox"
name={name}
value={value}
checked={checked}
onChange={() => {}}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
readOnly
/>
</label>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Checkbox.container";
export type { CheckboxProps } from "./Checkbox.types";
-17
View File
@@ -1,17 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import Header from "./Header";
import HomeHeader from "./HomeHeader";
export default function ConditionalHeader() {
const pathname = usePathname();
// Show HomeHeader only on the homepage (/)
if (pathname === "/") {
return <HomeHeader />;
}
// Show regular Header on all other pages
return <Header />;
}
@@ -0,0 +1,17 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import { ConditionalHeaderView } from "./ConditionalHeader.view";
import type { ConditionalHeaderProps } from "./ConditionalHeader.types";
const ConditionalHeaderContainer = memo<ConditionalHeaderProps>(() => {
const pathname = usePathname();
const isHomePage = pathname === "/";
return <ConditionalHeaderView isHomePage={isHomePage} />;
});
ConditionalHeaderContainer.displayName = "ConditionalHeader";
export default ConditionalHeaderContainer;
@@ -0,0 +1,7 @@
export interface ConditionalHeaderProps {
// Currently no props, but keeping interface for future extensibility
}
export interface ConditionalHeaderViewProps {
isHomePage: boolean;
}
@@ -0,0 +1,12 @@
import HomeHeader from "../HomeHeader";
import Header from "../Header";
import type { ConditionalHeaderViewProps } from "./ConditionalHeader.types";
export function ConditionalHeaderView({
isHomePage,
}: ConditionalHeaderViewProps) {
if (isHomePage) {
return <HomeHeader />;
}
return <Header />;
}
@@ -0,0 +1,2 @@
export { default } from "./ConditionalHeader.container";
export type { ConditionalHeaderProps } from "./ConditionalHeader.types";
-138
View File
@@ -1,138 +0,0 @@
"use client";
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
import type { BlogPost } from "../../lib/content";
interface ContentContainerProps {
post: BlogPost;
width?: string;
size?: "xs" | "responsive";
}
const ContentContainer = memo<ContentContainerProps>(
({ post, width = "200px", size = "responsive" }) => {
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug: string): string => {
const icons = [
getAssetPath(ASSETS.ICON_1),
getAssetPath(ASSETS.ICON_2),
getAssetPath(ASSETS.ICON_3),
];
if (!slug) return icons[0];
// Use the same cycling logic as background images to ensure matching
const slugOrder = [
"building-community-trust",
"operational-security-mutual-aid",
"making-decisions-without-hierarchy",
"resolving-active-conflicts",
];
const index = slugOrder.indexOf(slug);
const finalIndex = index >= 0 ? index % icons.length : 0;
return icons[finalIndex];
};
const iconImage = getIconImage(post.slug);
// Choose styling based on size prop
const containerClasses =
size === "xs"
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
return (
<div
className={`${containerClasses} ${
size === "responsive"
? "max-w-[298px] sm:max-w-[479px] lg:max-w-[365px] xl:max-w-[623px]"
: ""
}`}
style={size === "responsive" ? {} : { width }}
>
{/* Content Container - gap between icon and text */}
<div
className={
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-008)]"
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"
}
>
{/* Icon */}
<div className="w-[60px] h-[30px] flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconImage}
alt={`Icon for ${post.frontmatter.title}`}
className="w-[60px] h-[30px] object-contain"
/>
</div>
{/* Text Container */}
<div
className={
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-004)]"
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"
}
>
{/* Title */}
<h3
className={
size === "xs"
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
}
>
{post.frontmatter.title}
</h3>
{/* Description */}
<p
className={
size === "xs"
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]"
}
>
{post.frontmatter.description}
</p>
</div>
</div>
{/* Metadata Container - horizontal with 8px gap */}
<div className="flex items-center gap-[var(--measures-spacing-008)]">
{/* Author Name */}
<span
className={
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
}
>
{post.frontmatter.author}
</span>
{/* Date */}
<span
className={
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
}
>
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
})}
</span>
</div>
</div>
);
},
);
ContentContainer.displayName = "ContentContainer";
export default ContentContainer;
@@ -0,0 +1,100 @@
"use client";
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
import ContentContainerView from "./ContentContainer.view";
import type { ContentContainerProps } from "./ContentContainer.types";
const ContentContainerContainer = memo<ContentContainerProps>(
({ post, width = "200px", size = "responsive" }) => {
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug: string): string => {
const icons = [
getAssetPath(ASSETS.ICON_1),
getAssetPath(ASSETS.ICON_2),
getAssetPath(ASSETS.ICON_3),
];
if (!slug) return icons[0];
// Use the same cycling logic as background images to ensure matching
const slugOrder = [
"building-community-trust",
"operational-security-mutual-aid",
"making-decisions-without-hierarchy",
"resolving-active-conflicts",
];
const index = slugOrder.indexOf(slug);
const finalIndex = index >= 0 ? index % icons.length : 0;
return icons[finalIndex];
};
const iconImage = getIconImage(post.slug);
// Format date
const formattedDate = new Date(post.frontmatter.date).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
},
);
// Choose styling based on size prop
const containerClasses =
size === "xs"
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
const contentGapClasses =
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-008)]"
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]";
const textGapClasses =
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-004)]"
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]";
const titleClasses =
size === "xs"
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors";
const descriptionClasses =
size === "xs"
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]";
const authorClasses =
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
const dateClasses =
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
return (
<ContentContainerView
post={post}
width={width}
size={size}
iconImage={iconImage}
containerClasses={containerClasses}
contentGapClasses={contentGapClasses}
textGapClasses={textGapClasses}
titleClasses={titleClasses}
descriptionClasses={descriptionClasses}
authorClasses={authorClasses}
dateClasses={dateClasses}
formattedDate={formattedDate}
/>
);
},
);
ContentContainerContainer.displayName = "ContentContainer";
export default ContentContainerContainer;
@@ -0,0 +1,22 @@
import type { BlogPost } from "../../../lib/content";
export interface ContentContainerProps {
post: BlogPost;
width?: string;
size?: "xs" | "responsive";
}
export interface ContentContainerViewProps {
post: BlogPost;
width: string;
size: "xs" | "responsive";
iconImage: string;
containerClasses: string;
contentGapClasses: string;
textGapClasses: string;
titleClasses: string;
descriptionClasses: string;
authorClasses: string;
dateClasses: string;
formattedDate: string;
}
@@ -0,0 +1,61 @@
import { memo } from "react";
import type { ContentContainerViewProps } from "./ContentContainer.types";
function ContentContainerView({
post,
width,
size,
iconImage,
containerClasses,
contentGapClasses,
textGapClasses,
titleClasses,
descriptionClasses,
authorClasses,
dateClasses,
formattedDate,
}: ContentContainerViewProps) {
return (
<div
className={containerClasses}
style={size === "responsive" ? {} : { width }}
>
{/* Content Container - gap between icon and text */}
<div className={contentGapClasses}>
{/* Icon */}
<div className="w-[60px] h-[30px] flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconImage}
alt={`Icon for ${post.frontmatter.title}`}
className="w-[60px] h-[30px] object-contain"
/>
</div>
{/* Text Container */}
<div className={textGapClasses}>
{/* Title */}
<h3 className={titleClasses}>{post.frontmatter.title}</h3>
{/* Description */}
<p className={descriptionClasses}>
{post.frontmatter.description}
</p>
</div>
</div>
{/* Metadata Container - horizontal with 8px gap */}
<div className="flex items-center gap-[var(--measures-spacing-008)]">
{/* Author Name */}
<span className={authorClasses}>{post.frontmatter.author}</span>
{/* Date */}
<span className={dateClasses}>{formattedDate}</span>
</div>
</div>
);
}
ContentContainerView.displayName = "ContentContainerView";
export default memo(ContentContainerView);
@@ -0,0 +1,2 @@
export { default } from "./ContentContainer.container";
export type { ContentContainerProps } from "./ContentContainer.types";
@@ -1,39 +1,10 @@
"use client"; "use client";
import { memo } from "react"; import { memo } from "react";
import Button from "./Button"; import ContentLockupView from "./ContentLockup.view";
import { getAssetPath } from "../../lib/assetUtils"; import type { ContentLockupProps, VariantStyle } from "./ContentLockup.types";
interface ContentLockupProps { const ContentLockupContainer = memo<ContentLockupProps>(
title?: string;
subtitle?: string;
description?: string;
ctaText?: string;
ctaHref?: string;
buttonClassName?: string;
variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse";
linkText?: string;
linkHref?: string;
alignment?: "center" | "left";
/**
* Optional id to attach to the primary title heading.
* Useful when a parent section uses aria-labelledby.
*/
titleId?: string;
}
interface VariantStyle {
container: string;
textContainer: string;
titleGroup: string;
titleContainer: string;
title: string;
subtitle: string;
description?: string;
shape: string;
}
const ContentLockup = memo<ContentLockupProps>(
({ ({
title, title,
subtitle, subtitle,
@@ -125,102 +96,23 @@ const ContentLockup = memo<ContentLockupProps>(
const styles = variantStyles[variant] || variantStyles.hero; const styles = variantStyles[variant] || variantStyles.hero;
return ( return (
<div className={styles.container}> <ContentLockupView
{variant === "ask" || variant === "ask-inverse" ? ( title={title}
/* Simplified structure for ask variant */ subtitle={subtitle}
<div description={description}
className={`${styles.titleGroup} ${ ctaText={ctaText}
alignment === "left" ? "text-left" : "text-center" buttonClassName={buttonClassName}
}`} variant={variant}
> linkText={linkText}
<div linkHref={linkHref}
className={`${styles.titleContainer} ${ alignment={alignment}
alignment === "left" ? "justify-start" : "justify-center" titleId={titleId}
}`} styles={styles}
> />
{title ? (
<h1 id={titleId} className={styles.title}>
{title}
</h1>
) : null}
</div>
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
</div>
) : (
/* Full structure for other variants */
<div className={styles.textContainer}>
{/* Title and subtitle group */}
<div className={styles.titleGroup}>
{/* Title container */}
<div className={styles.titleContainer}>
{title ? (
<h1 id={titleId} className={styles.title}>
{title}
</h1>
) : null}
{variant === "hero" && (
<img
src={getAssetPath("assets/Shapes_1.svg")}
alt=""
className={styles.shape}
role="presentation"
/>
)}
</div>
{/* Subtitle */}
{subtitle ? (
<h2 className={styles.subtitle}>{subtitle}</h2>
) : null}
</div>
{/* Description */}
{description && <p className={styles.description}>{description}</p>}
</div>
)}
{/* Link for feature variant */}
{variant === "feature" && linkText && (
<a
href={linkHref || "#"}
className="font-inter font-medium text-[16px] leading-[20px] underline text-[var(--color-content-default-primary)] hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[#171717] rounded-sm px-1 py-0.5"
>
{linkText}
</a>
)}
{/* CTA Button */}
{ctaText && (
<div className="flex justify-start">
{/* Small button for xsm and sm breakpoints */}
<div className="block md:hidden">
<Button variant="primary" size="small">
{ctaText}
</Button>
</div>
{/* Large button for md and lg breakpoints */}
<div className="hidden md:block xl:hidden">
<Button
variant="primary"
size="large"
className={buttonClassName}
>
{ctaText}
</Button>
</div>
{/* XLarge button for xl breakpoint */}
<div className="hidden xl:block">
<Button variant="primary" size="xlarge">
{ctaText}
</Button>
</div>
</div>
)}
</div>
); );
}, },
); );
ContentLockup.displayName = "ContentLockup"; ContentLockupContainer.displayName = "ContentLockup";
export default ContentLockup; export default ContentLockupContainer;
@@ -0,0 +1,43 @@
export interface ContentLockupProps {
title?: string;
subtitle?: string;
description?: string;
ctaText?: string;
ctaHref?: string;
buttonClassName?: string;
variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse";
linkText?: string;
linkHref?: string;
alignment?: "center" | "left";
/**
* Optional id to attach to the primary title heading.
* Useful when a parent section uses aria-labelledby.
*/
titleId?: string;
}
export interface VariantStyle {
container: string;
textContainer: string;
titleGroup: string;
titleContainer: string;
title: string;
subtitle: string;
description?: string;
shape: string;
}
export interface ContentLockupViewProps {
title?: string;
subtitle?: string;
description?: string;
ctaText?: string;
ctaHref?: string;
buttonClassName: string;
variant: "hero" | "feature" | "learn" | "ask" | "ask-inverse";
linkText?: string;
linkHref?: string;
alignment: "center" | "left";
titleId?: string;
styles: VariantStyle;
}
@@ -0,0 +1,119 @@
"use client";
import { memo } from "react";
import Button from "../Button";
import { getAssetPath } from "../../../lib/assetUtils";
import type { ContentLockupViewProps } from "./ContentLockup.types";
function ContentLockupView({
title,
subtitle,
description,
ctaText,
buttonClassName,
variant,
linkText,
linkHref,
alignment,
titleId,
styles,
}: ContentLockupViewProps) {
return (
<div className={styles.container}>
{variant === "ask" || variant === "ask-inverse" ? (
/* Simplified structure for ask variant */
<div
className={`${styles.titleGroup} ${
alignment === "left" ? "text-left" : "text-center"
}`}
>
<div
className={`${styles.titleContainer} ${
alignment === "left" ? "justify-start" : "justify-center"
}`}
>
{title ? (
<h1 id={titleId} className={styles.title}>
{title}
</h1>
) : null}
</div>
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
</div>
) : (
/* Full structure for other variants */
<div className={styles.textContainer}>
{/* Title and subtitle group */}
<div className={styles.titleGroup}>
{/* Title container */}
<div className={styles.titleContainer}>
{title ? (
<h1 id={titleId} className={styles.title}>
{title}
</h1>
) : null}
{variant === "hero" && (
<img
src={getAssetPath("assets/Shapes_1.svg")}
alt=""
className={styles.shape}
role="presentation"
/>
)}
</div>
{/* Subtitle */}
{subtitle ? (
<h2 className={styles.subtitle}>{subtitle}</h2>
) : null}
</div>
{/* Description */}
{description && <p className={styles.description}>{description}</p>}
</div>
)}
{/* Link for feature variant */}
{variant === "feature" && linkText && (
<a
href={linkHref || "#"}
className="font-inter font-medium text-[16px] leading-[20px] underline text-[var(--color-content-default-primary)] hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[#171717] rounded-sm px-1 py-0.5"
>
{linkText}
</a>
)}
{/* CTA Button */}
{ctaText && (
<div className="flex justify-start">
{/* Small button for xsm and sm breakpoints */}
<div className="block md:hidden">
<Button variant="primary" size="small">
{ctaText}
</Button>
</div>
{/* Large button for md and lg breakpoints */}
<div className="hidden md:block xl:hidden">
<Button
variant="primary"
size="large"
className={buttonClassName}
>
{ctaText}
</Button>
</div>
{/* XLarge button for xl breakpoint */}
<div className="hidden xl:block">
<Button variant="primary" size="xlarge">
{ctaText}
</Button>
</div>
</div>
)}
</div>
);
}
ContentLockupView.displayName = "ContentLockupView";
export default memo(ContentLockupView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./ContentLockup.container";
export type { ContentLockupProps } from "./ContentLockup.types";
-106
View File
@@ -1,106 +0,0 @@
"use client";
import { memo } from "react";
import Link from "next/link";
import ContentContainer from "./ContentContainer";
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
import type { BlogPost } from "../../lib/content";
/**
* ContentThumbnailTemplate component for displaying blog post previews
* Simplified version to debug infinite loop
*/
interface ContentThumbnailTemplateProps {
post: BlogPost;
className?: string;
variant?: "vertical" | "horizontal";
slugOrder?: string[];
}
const ContentThumbnailTemplate = memo<ContentThumbnailTemplateProps>(
({ post, className = "", variant = "vertical" }) => {
// Get article-specific background image from frontmatter
const getBackgroundImage = (
post: BlogPost,
variant: "vertical" | "horizontal",
): string => {
// Check if post has thumbnail images defined in frontmatter
if (post.frontmatter?.thumbnail) {
const imageName =
variant === "vertical"
? post.frontmatter.thumbnail.vertical
: post.frontmatter.thumbnail.horizontal;
if (imageName) {
// Return path to image in public/content/blog directory
return `/content/blog/${imageName}`;
}
}
// Fallback to default images if no thumbnail specified
const fallbackImages: Record<string, string> = {
vertical: getAssetPath(ASSETS.VERTICAL_1),
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
};
return fallbackImages[variant] || fallbackImages.vertical;
};
const backgroundImage = getBackgroundImage(post, variant);
if (variant === "vertical") {
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
{/* Background SVG - fills container with maintained aspect */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-full object-cover"
/>
{/* Gradient overlay for better text readability */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
</div>
{/* Content Section - positioned within the padding constraints */}
<ContentContainer post={post} width="200px" size="xs" />
</div>
</Link>
);
}
// Horizontal variant
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-[225.5px] object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
</div>
{/* Content - positioned within the padding constraints */}
<ContentContainer post={post} width="230px" size="xs" />
</div>
</Link>
);
},
);
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
export default ContentThumbnailTemplate;
@@ -0,0 +1,52 @@
"use client";
import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
({ post, className = "", variant = "vertical" }) => {
// Get article-specific background image from frontmatter
const getBackgroundImage = (
post: ContentThumbnailTemplateProps["post"],
variant: "vertical" | "horizontal",
): string => {
// Check if post has thumbnail images defined in frontmatter
if (post.frontmatter?.thumbnail) {
const imageName =
variant === "vertical"
? post.frontmatter.thumbnail.vertical
: post.frontmatter.thumbnail.horizontal;
if (imageName) {
// Return path to image in public/content/blog directory
return `/content/blog/${imageName}`;
}
}
// Fallback to default images if no thumbnail specified
const fallbackImages: Record<string, string> = {
vertical: getAssetPath(ASSETS.VERTICAL_1),
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
};
return fallbackImages[variant] || fallbackImages.vertical;
};
const backgroundImage = getBackgroundImage(post, variant);
return (
<ContentThumbnailTemplateView
post={post}
className={className}
variant={variant}
backgroundImage={backgroundImage}
/>
);
},
);
ContentThumbnailTemplateContainer.displayName = "ContentThumbnailTemplate";
export default ContentThumbnailTemplateContainer;
@@ -0,0 +1,15 @@
import type { BlogPost } from "../../../lib/content";
export interface ContentThumbnailTemplateProps {
post: BlogPost;
className?: string;
variant?: "vertical" | "horizontal";
slugOrder?: string[];
}
export interface ContentThumbnailTemplateViewProps {
post: BlogPost;
className: string;
variant: "vertical" | "horizontal";
backgroundImage: string;
}
@@ -0,0 +1,66 @@
import { memo } from "react";
import Link from "next/link";
import ContentContainer from "../ContentContainer";
import type { ContentThumbnailTemplateViewProps } from "./ContentThumbnailTemplate.types";
function ContentThumbnailTemplateView({
post,
className,
variant,
backgroundImage,
}: ContentThumbnailTemplateViewProps) {
if (variant === "vertical") {
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
{/* Background SVG - fills container with maintained aspect */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-full object-cover"
/>
{/* Gradient overlay for better text readability */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
</div>
{/* Content Section - positioned within the padding constraints */}
<ContentContainer post={post} width="200px" size="xs" />
</div>
</Link>
);
}
// Horizontal variant
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-[225.5px] object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
</div>
{/* Content - positioned within the padding constraints */}
<ContentContainer post={post} width="230px" size="xs" />
</div>
</Link>
);
}
ContentThumbnailTemplateView.displayName = "ContentThumbnailTemplateView";
export default memo(ContentThumbnailTemplateView);
@@ -0,0 +1,2 @@
export { default } from "./ContentThumbnailTemplate.container";
export type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
@@ -1,23 +1,18 @@
"use client"; "use client";
import { forwardRef, memo, useCallback } from "react"; import { forwardRef, memo, useCallback } from "react";
import { ContextMenuItemView } from "./ContextMenuItem.view";
import type { ContextMenuItemProps } from "./ContextMenuItem.types";
interface SelectOptionProps { const ContextMenuItemContainer = forwardRef<
children?: React.ReactNode; HTMLDivElement,
selected?: boolean; ContextMenuItemProps
disabled?: boolean; >(
className?: string;
onClick?: (
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
) => void;
size?: "small" | "medium" | "large";
}
const SelectOption = forwardRef<HTMLDivElement, SelectOptionProps>(
( (
{ {
children, children,
selected = false, selected = false,
hasSubmenu = false,
disabled = false, disabled = false,
className = "", className = "",
onClick, onClick,
@@ -83,40 +78,23 @@ const SelectOption = forwardRef<HTMLDivElement, SelectOptionProps>(
); );
return ( return (
<div <ContextMenuItemView
ref={ref} ref={ref}
className={itemClasses} selected={selected}
role="option" hasSubmenu={hasSubmenu}
tabIndex={disabled ? -1 : 0} disabled={disabled}
aria-selected={selected} className={className}
aria-disabled={disabled} itemClasses={itemClasses}
onClick={handleClick} handleClick={handleClick}
onKeyDown={handleKeyDown} handleKeyDown={handleKeyDown}
{...props} {...props}
> >
<div className="flex items-center gap-[8px]"> {children}
{selected && ( </ContextMenuItemView>
<svg
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
<span>{children}</span>
</div>
</div>
); );
}, },
); );
SelectOption.displayName = "SelectOption"; ContextMenuItemContainer.displayName = "ContextMenuItem";
export default memo(SelectOption); export default memo(ContextMenuItemContainer);
@@ -0,0 +1,23 @@
export interface ContextMenuItemProps
extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
selected?: boolean;
hasSubmenu?: boolean;
disabled?: boolean;
className?: string;
onClick?: (
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
) => void;
size?: "small" | "medium" | "large";
}
export interface ContextMenuItemViewProps {
children?: React.ReactNode;
selected: boolean;
hasSubmenu: boolean;
disabled: boolean;
className: string;
itemClasses: string;
handleClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
@@ -0,0 +1,71 @@
import { forwardRef } from "react";
import type { ContextMenuItemViewProps } from "./ContextMenuItem.types";
export const ContextMenuItemView = forwardRef<
HTMLDivElement,
ContextMenuItemViewProps
>(
(
{
children,
selected,
hasSubmenu,
disabled,
itemClasses,
handleClick,
handleKeyDown,
...props
},
ref,
) => {
return (
<div
ref={ref}
className={itemClasses}
role="menuitem"
tabIndex={disabled ? -1 : 0}
aria-current={selected ? "true" : undefined}
aria-disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...props}
>
<div className="flex items-center gap-[8px]">
{selected && (
<svg
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
<span>{children}</span>
</div>
{hasSubmenu && (
<svg
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</div>
);
},
);
ContextMenuItemView.displayName = "ContextMenuItemView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./ContextMenuItem.container";
export type { ContextMenuItemProps } from "./ContextMenuItem.types";
-287
View File
@@ -1,287 +0,0 @@
"use client";
import { memo } from "react";
import { usePathname } from "next/navigation";
import Logo from "./Logo";
import MenuBar from "./MenuBar";
import MenuBarItem from "./MenuBarItem";
import Button from "./Button";
import AvatarContainer from "./AvatarContainer";
import Avatar from "./Avatar";
import HeaderTab from "./HeaderTab";
const HomeHeader = memo(() => {
const pathname = usePathname();
// Schema markup for site navigation (home page specific)
const schemaData = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "CommunityRule",
url: "https://communityrule.com",
description: "Build operating manuals for successful communities",
potentialAction: {
"@type": "SearchAction",
target: "https://communityrule.com/search?q={search_term_string}",
"query-input": "required name=search_term_string",
},
};
const navigationItems = [
{ href: "#", text: "Use cases", extraPadding: true },
{ href: "/learn", text: "Learn" },
{ href: "#", text: "About" },
];
const avatarImages = [
{ src: "/assets/Avatar_1.png", alt: "Avatar 1" },
{ src: "/assets/Avatar_2.png", alt: "Avatar 2" },
{ src: "/assets/Avatar_3.png", alt: "Avatar 3" },
];
const logoConfig = [
{
breakpoint: "block sm:hidden",
size: "homeHeaderXsmall" as const,
showText: false,
},
{
breakpoint: "hidden sm:block md:hidden",
size: "homeHeaderSm" as const,
showText: true,
},
{
breakpoint: "hidden md:block lg:hidden",
size: "homeHeaderMd" as const,
showText: true,
},
{
breakpoint: "hidden lg:block xl:hidden",
size: "homeHeaderLg" as const,
showText: true,
},
{
breakpoint: "hidden xl:block",
size: "homeHeaderXl" as const,
showText: true,
},
];
type NavSize =
| "default"
| "xsmall"
| "xsmallUseCases"
| "home"
| "homeMd"
| "homeUseCases"
| "large"
| "largeUseCases"
| "homeXlarge"
| "xlarge";
const renderNavigationItems = (size: NavSize) => {
return navigationItems.map((item, index) => (
<MenuBarItem
key={index}
href={item.href}
size={
item.extraPadding &&
(size === "xsmall" ||
size === "default" ||
size === "home" ||
size === "homeMd" ||
size === "large" ||
size === "homeXlarge")
? size === "home" || size === "homeMd"
? "homeMd"
: size === "large"
? "large"
: size === "homeXlarge"
? "homeXlarge"
: "xsmallUseCases"
: size
}
variant={
size === "xsmall" ||
size === "default" ||
size === "home" ||
size === "homeMd" ||
size === "large" ||
size === "homeXlarge"
? "home"
: "default"
}
isActive={pathname === item.href}
ariaLabel={`Navigate to ${item.text} page`}
>
{item.text}
</MenuBarItem>
));
};
const renderAvatarGroup = (
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
) => {
return (
<AvatarContainer size={containerSize}>
{avatarImages.map((avatar, index) => (
<Avatar
key={index}
src={avatar.src}
alt={avatar.alt}
size={avatarSize}
/>
))}
</AvatarContainer>
);
};
const renderLoginButton = (size: NavSize) => {
return (
<MenuBarItem
href="#"
size={size}
variant={size === "xsmall" || size === "default" ? "home" : "default"}
ariaLabel="Log in to your account"
>
Log in
</MenuBarItem>
);
};
const renderCreateRuleButton = (
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
) => {
return (
<Button
size={buttonSize}
variant="secondary"
ariaLabel="Create a new rule with avatar decoration"
>
{renderAvatarGroup(containerSize, avatarSize)}
<span>Create rule</span>
</Button>
);
};
const renderLogo = (
size:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
| "homeHeaderMd"
| "homeHeaderLg"
| "homeHeaderXl"
| "header"
| "headerMd"
| "headerLg"
| "headerXl"
| "footer"
| "footerLg",
showText: boolean,
) => {
return <Logo size={size} showText={showText} />;
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
/>
<header
className="w-full bg-transparent overflow-hidden"
role="banner"
aria-label="Home page navigation header"
>
<nav
className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] px-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
role="navigation"
aria-label="Main navigation"
>
<HeaderTab className="flex items-center self-end" stretch={true}>
{/* Logo - Consistent left positioning within HeaderTab */}
<div>
{logoConfig.map((config, index) => (
<div key={index} className={config.breakpoint}>
{renderLogo(config.size, config.showText)}
</div>
))}
</div>
{/* XSmall menu bar - positioned next to logo */}
<div className="block sm:hidden -me-[2px]">
<MenuBar size="default">
{renderNavigationItems("xsmall")}
{renderLoginButton("xsmall")}
</MenuBar>
</div>
</HeaderTab>
{/* Navigation Links - Centered in header for SM and up */}
<div className="absolute left-1/2 transform -translate-x-1/2 hidden sm:block">
<div className="hidden sm:block md:hidden">
<MenuBar size="default">
{renderNavigationItems("xsmall")}
{renderLoginButton("xsmall")}
</MenuBar>
</div>
<div className="hidden md:block lg:hidden">
<MenuBar size="medium">{renderNavigationItems("homeMd")}</MenuBar>
</div>
<div className="hidden lg:block xl:hidden">
<MenuBar size="large">{renderNavigationItems("large")}</MenuBar>
</div>
<div className="hidden xl:block">
<MenuBar size="large">
{renderNavigationItems("homeXlarge")}
</MenuBar>
</div>
</div>
{/* Authentication Elements - Consistent right alignment outside HeaderTab */}
<div className="flex items-center">
{/* XSmall and Small breakpoints - create rule button outside HeaderTab */}
<div className="block md:hidden">
{renderCreateRuleButton("xsmall", "small", "small")}
</div>
{/* Medium breakpoint - login outside HeaderTab, create rule outside */}
<div className="hidden md:block lg:hidden absolute right-[var(--spacing-measures-spacing-016)]">
<div className="flex items-center gap-[var(--spacing-scale-010)]">
{renderLoginButton("homeMd")}
{renderCreateRuleButton("small", "medium", "medium")}
</div>
</div>
{/* Large breakpoint */}
<div className="hidden lg:flex xl:hidden items-center">
<div className="flex items-center gap-[var(--spacing-scale-004)]">
{renderLoginButton("large")}
{renderCreateRuleButton("large", "large", "large")}
</div>
</div>
{/* XLarge breakpoint */}
<div className="hidden xl:flex items-center">
<div className="flex items-center gap-[var(--spacing-scale-004)]">
{renderLoginButton("homeXlarge")}
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
</div>
</div>
</div>
</nav>
</header>
</>
);
});
HomeHeader.displayName = "HomeHeader";
export default HomeHeader;
@@ -0,0 +1,83 @@
"use client";
import { memo, useMemo } from "react";
import { usePathname } from "next/navigation";
import { useSchemaData } from "../../hooks";
import HomeHeaderView from "./HomeHeader.view";
import type { HomeHeaderProps } from "./HomeHeader.types";
const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
const pathname = usePathname();
const { schemaData } = useSchemaData();
// Navigation items configuration
const navigationItems = useMemo(
() => [
{
label: "Home",
href: "/",
isActive: pathname === "/",
},
{
label: "Learn",
href: "/learn",
isActive: pathname === "/learn",
},
{
label: "Monitor",
href: "/monitor",
isActive: pathname === "/monitor",
},
{
label: "Blog",
href: "/blog",
isActive: pathname?.startsWith("/blog") ?? false,
},
],
[pathname],
);
// Avatar images configuration
const avatarImages = useMemo(
() => [
{
src: "/assets/avatar-1.svg",
alt: "User avatar 1",
},
{
src: "/assets/avatar-2.svg",
alt: "User avatar 2",
},
{
src: "/assets/avatar-3.svg",
alt: "User avatar 3",
},
],
[],
);
// Logo configuration
const logoConfig = useMemo(
() => ({
src: "/assets/logo.svg",
alt: "Community Rule Logo",
width: 120,
height: 32,
}),
[],
);
return (
<HomeHeaderView
pathname={pathname}
schemaData={schemaData}
navigationItems={navigationItems}
avatarImages={avatarImages}
logoConfig={logoConfig}
/>
);
});
HomeHeaderContainer.displayName = "HomeHeader";
export default HomeHeaderContainer;
@@ -0,0 +1,23 @@
export interface HomeHeaderProps {
// Currently no props, but keeping interface for future extensibility
}
export interface HomeHeaderViewProps {
pathname: string;
schemaData: object;
navigationItems: Array<{
label: string;
href: string;
isActive: boolean;
}>;
avatarImages: Array<{
src: string;
alt: string;
}>;
logoConfig: {
src: string;
alt: string;
width: number;
height: number;
};
}
@@ -0,0 +1,67 @@
"use client";
import { memo } from "react";
import Script from "next/script";
import Logo from "../Logo";
import NavigationItem from "../NavigationItem";
import AvatarContainer from "../AvatarContainer";
import Button from "../Button";
import type { HomeHeaderViewProps } from "./HomeHeader.types";
function HomeHeaderView({
pathname,
schemaData,
navigationItems,
avatarImages,
logoConfig,
}: HomeHeaderViewProps) {
return (
<>
<Script
id="home-header-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
/>
<header className="sticky top-0 z-50 bg-[var(--color-surface-default-primary)] border-b border-[var(--color-border-default-tertiary)]">
<div className="max-w-[1440px] mx-auto px-[var(--spacing-scale-016)] md:px-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-064)]">
<div className="flex items-center justify-between h-[var(--measures-sizing-064)] md:h-[var(--measures-sizing-080)]">
<div className="flex items-center gap-[var(--spacing-scale-040)]">
<Logo
src={logoConfig.src}
alt={logoConfig.alt}
width={logoConfig.width}
height={logoConfig.height}
/>
<nav className="hidden md:flex items-center gap-[var(--spacing-scale-016)]">
{navigationItems.map((item) => (
<NavigationItem
key={item.href}
href={item.href}
isActive={item.isActive}
>
{item.label}
</NavigationItem>
))}
</nav>
</div>
<div className="flex items-center gap-[var(--spacing-scale-016)]">
<AvatarContainer avatars={avatarImages} />
<Button
href="/learn"
variant="primary"
size="medium"
className="hidden md:inline-flex"
>
Get Started
</Button>
</div>
</div>
</div>
</header>
</>
);
}
HomeHeaderView.displayName = "HomeHeaderView";
export default memo(HomeHeaderView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./HomeHeader.container";
export type { HomeHeaderProps } from "./HomeHeader.types";
@@ -1,27 +1,11 @@
"use client"; "use client";
import { memo, forwardRef } from "react"; import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../hooks"; import { useComponentId, useFormField } from "../../hooks";
import { InputView } from "./Input.view";
import type { InputProps } from "./Input.types";
interface InputProps extends Omit< const InputContainer = forwardRef<HTMLInputElement, InputProps>(
React.InputHTMLAttributes<HTMLInputElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
label?: string;
placeholder?: string;
value?: string;
onChange?: (_e: React.ChangeEvent<HTMLInputElement>) => void;
onFocus?: (_e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
( (
{ {
size = "medium", size = "medium",
@@ -159,38 +143,34 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
}); });
return ( return (
<div className={containerClasses}> <InputView
{label && ( ref={ref}
<label inputId={inputId}
id={labelId} labelId={labelId}
htmlFor={inputId} size={size}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`} labelVariant={labelVariant}
> state={state}
{label} disabled={disabled}
</label> error={error}
)} label={label}
<div className={disabled ? "opacity-40" : ""}> placeholder={placeholder}
<input value={value}
ref={ref} name={name}
id={inputId} type={type}
name={name} className={className}
type={type} containerClasses={containerClasses}
value={value} labelClasses={labelClasses}
placeholder={placeholder} inputClasses={inputClasses}
onChange={handleChange} borderRadius={currentSize.radius}
onFocus={handleFocus} handleChange={handleChange}
onBlur={handleBlur} handleFocus={handleFocus}
disabled={disabled} handleBlur={handleBlur}
className={inputClasses} {...props}
style={{ borderRadius: currentSize.radius }} />
{...props}
/>
</div>
</div>
); );
}, },
); );
Input.displayName = "Input"; InputContainer.displayName = "Input";
export default memo(Input); export default memo(InputContainer);
+38
View File
@@ -0,0 +1,38 @@
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "onChange" | "onFocus" | "onBlur"> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
label?: string;
placeholder?: string;
value?: string;
onChange?: (_e: React.ChangeEvent<HTMLInputElement>) => void;
onFocus?: (_e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
}
export interface InputViewProps {
inputId: string;
labelId: string;
size: "small" | "medium" | "large";
labelVariant: "default" | "horizontal";
state: "default" | "active" | "hover" | "focus";
disabled: boolean;
error: boolean;
label?: string;
placeholder?: string;
value?: string;
name?: string;
type: string;
className: string;
containerClasses: string;
labelClasses: string;
inputClasses: string;
borderRadius: string;
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleFocus: (e: React.FocusEvent<HTMLInputElement>) => void;
handleBlur: (e: React.FocusEvent<HTMLInputElement>) => void;
}
+60
View File
@@ -0,0 +1,60 @@
import { forwardRef } from "react";
import type { InputViewProps } from "./Input.types";
export const InputView = forwardRef<HTMLInputElement, InputViewProps>(
(
{
inputId,
labelId,
label,
placeholder,
value,
name,
type,
disabled,
className,
containerClasses,
labelClasses,
inputClasses,
borderRadius,
handleChange,
handleFocus,
handleBlur,
...props
},
ref,
) => {
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className={disabled ? "opacity-40" : ""}>
<input
ref={ref}
id={inputId}
name={name}
type={type}
value={value}
placeholder={placeholder}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
className={inputClasses}
style={{ borderRadius }}
{...props}
/>
</div>
</div>
);
},
);
InputView.displayName = "InputView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Input.container";
export type { InputProps } from "./Input.types";
-116
View File
@@ -1,116 +0,0 @@
"use client";
import { useState, useEffect, memo } from "react";
import Image from "next/image";
interface Logo {
src: string;
alt: string;
size?: string;
order?: string;
}
interface LogoWallProps {
logos?: Logo[];
}
const LogoWall = memo<LogoWallProps>(({ logos = [] }) => {
const [isVisible, setIsVisible] = useState(false);
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
const defaultLogos: Logo[] = [
{
src: "/assets/Section/Logo_FoodNotBombs.png",
alt: "Food Not Bombs",
size: "h-11 lg:h-14 xl:h-[70px]",
order: "order-1 sm:order-4", // Mobile: row 1 col 1, SM: row 2 col 1 (bottom left)
},
{
src: "/assets/Section/Logo_StartCOOP.png",
alt: "Start COOP",
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
order: "order-2 sm:order-2", // Mobile: row 1 col 2, SM: row 1 col 2 (top middle)
},
{
src: "/assets/Section/Logo_Metagov.png",
alt: "Metagov",
size: "h-6 lg:h-8 xl:h-[41px]",
order: "order-3 sm:order-1", // Mobile: row 2 col 1, SM: row 1 col 1 (top left)
},
{
src: "/assets/Section/Logo_OpenCivics.png",
alt: "Open Civics",
size: "h-8 lg:h-10 xl:h-[50px]",
order: "order-4 sm:order-5 md:order-6", // Mobile: row 2 col 2, SM: row 2 col 2, MD: swapped with Mutual Aid CO
},
{
src: "/assets/Section/Logo_MutualAidCO.png",
alt: "Mutual Aid CO",
size: "h-11 lg:h-14 xl:h-[70px]",
order: "order-5 sm:order-6 md:order-5", // Mobile: row 3 col 1, SM: row 2 col 3, MD: swapped with OpenCivics
},
{
src: "/assets/Section/Logo_CUBoulder.png",
alt: "CU Boulder",
size: "h-10 lg:h-12 xl:h-[60px]",
order: "order-6 sm:order-3", // Mobile: row 3 col 2, SM: row 1 col 3 (top right)
},
];
const displayLogos = logos.length > 0 ? logos : defaultLogos;
// Simple fade-in effect after component mounts
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true);
}, 100);
return () => clearTimeout(timer);
}, []);
return (
<section className="p-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-048)] xl:px-[160px] xl:py-[var(--spacing-scale-064)]">
<div className="flex flex-col gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-024)] xl:gap-[var(--spacing-scale-032)]">
{/* Label */}
<p className="font-inter font-medium text-[10px] leading-[12px] xl:text-[14px] xl:leading-[12px] uppercase text-[var(--color-content-default-secondary)] text-center">
Trusted by leading cooperators
</p>
{/* Logo Grid Container */}
<div
className={`transition-opacity duration-500 ${
isVisible ? "opacity-60" : "opacity-0"
}`}
>
<div className="grid grid-cols-2 grid-rows-3 sm:grid-cols-3 sm:grid-rows-2 md:flex md:justify-between md:items-center gap-x-[var(--spacing-scale-032)] gap-y-[var(--spacing-scale-032)] sm:gap-y-[var(--spacing-scale-048)]">
{displayLogos.map((logo, index) => (
<div
key={index}
className={`flex items-center justify-center transition-opacity duration-500 hover:opacity-100 ${
logo.order || ""
}`}
>
<Image
src={logo.src}
alt={logo.alt}
className={`${
logo.size || "h-8"
} w-auto object-contain transition-transform duration-500 hover:scale-105`}
priority={index < 2} // Prioritize first 2 logos for above-the-fold loading
unoptimized // Skip optimization for local images
width={0}
height={0}
sizes="100vw"
/>
</div>
))}
</div>
</div>
</div>
</section>
);
});
LogoWall.displayName = "LogoWall";
export default LogoWall;
@@ -0,0 +1,73 @@
"use client";
import { memo, useState, useEffect, useMemo } from "react";
import LogoWallView from "./LogoWall.view";
import type { LogoWallProps } from "./LogoWall.types";
const defaultLogos = [
{
src: "/assets/logo-1.svg",
alt: "Partner Logo 1",
width: 120,
height: 40,
},
{
src: "/assets/logo-2.svg",
alt: "Partner Logo 2",
width: 120,
height: 40,
},
{
src: "/assets/logo-3.svg",
alt: "Partner Logo 3",
width: 120,
height: 40,
},
{
src: "/assets/logo-4.svg",
alt: "Partner Logo 4",
width: 120,
height: 40,
},
{
src: "/assets/logo-5.svg",
alt: "Partner Logo 5",
width: 120,
height: 40,
},
{
src: "/assets/logo-6.svg",
alt: "Partner Logo 6",
width: 120,
height: 40,
},
];
const LogoWallContainer = memo<LogoWallProps>(
({ logos, className = "" }) => {
const [isVisible, setIsVisible] = useState(false);
const displayLogos = useMemo(() => logos || defaultLogos, [logos]);
useEffect(() => {
// Trigger fade-in animation after component mounts
const timer = setTimeout(() => {
setIsVisible(true);
}, 100);
return () => clearTimeout(timer);
}, []);
return (
<LogoWallView
isVisible={isVisible}
displayLogos={displayLogos}
className={className}
/>
);
},
);
LogoWallContainer.displayName = "LogoWall";
export default LogoWallContainer;
+20
View File
@@ -0,0 +1,20 @@
export interface LogoWallProps {
logos?: Array<{
src: string;
alt: string;
width?: number;
height?: number;
}>;
className?: string;
}
export interface LogoWallViewProps {
isVisible: boolean;
displayLogos: Array<{
src: string;
alt: string;
width?: number;
height?: number;
}>;
className: string;
}
+33
View File
@@ -0,0 +1,33 @@
import { memo } from "react";
import Image from "next/image";
import type { LogoWallViewProps } from "./LogoWall.types";
function LogoWallView({ isVisible, displayLogos, className }: LogoWallViewProps) {
return (
<div
className={`flex flex-wrap items-center justify-center gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-048)] transition-opacity duration-1000 ${
isVisible ? "opacity-100" : "opacity-0"
} ${className}`}
>
{displayLogos.map((logo, index) => (
<div
key={`${logo.src}-${index}`}
className="flex items-center justify-center grayscale hover:grayscale-0 transition-all duration-300"
>
<Image
src={logo.src}
alt={logo.alt}
width={logo.width || 120}
height={logo.height || 40}
className="object-contain"
loading="lazy"
/>
</div>
))}
</div>
);
}
LogoWallView.displayName = "LogoWallView";
export default memo(LogoWallView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./LogoWall.container";
export type { LogoWallProps } from "./LogoWall.types";
@@ -1,27 +1,10 @@
"use client";
import { memo } from "react"; import { memo } from "react";
import MenuBarItemView from "./MenuBarItem.view";
import type { MenuBarItemProps } from "./MenuBarItem.types";
interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> { const MenuBarItemContainer = memo<MenuBarItemProps>(
href?: string;
children?: React.ReactNode;
variant?: "default" | "home";
size?:
| "default"
| "xsmall"
| "xsmallUseCases"
| "home"
| "homeMd"
| "homeUseCases"
| "large"
| "largeUseCases"
| "homeXlarge"
| "xlarge";
className?: string;
disabled?: boolean;
isActive?: boolean;
ariaLabel?: string;
}
const MenuBarItem = memo<MenuBarItemProps>(
({ ({
href = "#", href = "#",
children, children,
@@ -166,22 +149,20 @@ const MenuBarItem = memo<MenuBarItemProps>(
...props, ...props,
}; };
if (disabled) {
return (
<span className={combinedStyles} {...accessibilityProps}>
{children}
</span>
);
}
return ( return (
<a href={href} className={combinedStyles} {...accessibilityProps}> <MenuBarItemView
href={href}
disabled={disabled}
className={className}
combinedStyles={combinedStyles}
accessibilityProps={accessibilityProps}
>
{children} {children}
</a> </MenuBarItemView>
); );
}, },
); );
MenuBarItem.displayName = "MenuBarItem"; MenuBarItemContainer.displayName = "MenuBarItem";
export default MenuBarItem; export default MenuBarItemContainer;
@@ -0,0 +1,30 @@
export interface MenuBarItemProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href?: string;
children?: React.ReactNode;
variant?: "default" | "home";
size?:
| "default"
| "xsmall"
| "xsmallUseCases"
| "home"
| "homeMd"
| "homeUseCases"
| "large"
| "largeUseCases"
| "homeXlarge"
| "xlarge";
className?: string;
disabled?: boolean;
isActive?: boolean;
ariaLabel?: string;
}
export interface MenuBarItemViewProps {
href: string;
children?: React.ReactNode;
disabled: boolean;
className: string;
combinedStyles: string;
accessibilityProps: React.HTMLAttributes<HTMLAnchorElement | HTMLSpanElement>;
}
@@ -0,0 +1,28 @@
import { memo } from "react";
import type { MenuBarItemViewProps } from "./MenuBarItem.types";
function MenuBarItemView({
href,
children,
disabled,
combinedStyles,
accessibilityProps,
}: MenuBarItemViewProps) {
if (disabled) {
return (
<span className={combinedStyles} {...accessibilityProps}>
{children}
</span>
);
}
return (
<a href={href} className={combinedStyles} {...accessibilityProps}>
{children}
</a>
);
}
MenuBarItemView.displayName = "MenuBarItemView";
export default memo(MenuBarItemView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./MenuBarItem.container";
export type { MenuBarItemProps } from "./MenuBarItem.types";
-137
View File
@@ -1,137 +0,0 @@
"use client";
import { memo } from "react";
import Image from "next/image";
interface MiniCardProps {
children?: React.ReactNode;
className?: string;
backgroundColor?: string;
panelContent?: string;
label?: string;
labelLine1?: string;
labelLine2?: string;
onClick?: () => void;
href?: string;
ariaLabel?: string;
}
const MiniCard = memo<MiniCardProps>(
({
children,
className = "",
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
panelContent,
label,
labelLine1,
labelLine2,
onClick,
href,
ariaLabel,
}) => {
const cardContent = (
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
{/* Top part - Inner panel */}
<div
className={`flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`}
>
{/* Content for the inner panel */}
{panelContent && (
<div className="flex items-center justify-center w-full h-full">
<Image
src={panelContent}
alt={
ariaLabel ||
`${labelLine1} ${labelLine2}` ||
label ||
"Feature icon"
}
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
width={58}
height={58}
sizes="(max-width: 768px) 50vw, 25vw"
loading="lazy"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
/>
</div>
)}
{children}
</div>
{/* Bottom part - Text container */}
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
{labelLine1 && labelLine2 ? (
<>
<div>{labelLine1}</div>
<div>{labelLine2}</div>
<div>&nbsp;</div>
</>
) : (
label
)}
</div>
</div>
);
// If href is provided, render as a link
if (href) {
return (
<a
href={href}
className="block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
aria-label={
ariaLabel ||
`${labelLine1} ${labelLine2}` ||
label ||
"Feature card"
}
tabIndex={0}
>
{cardContent}
</a>
);
}
// If onClick is provided, render as a button
if (onClick) {
return (
<button
onClick={onClick}
className="block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
aria-label={
ariaLabel ||
`${labelLine1} ${labelLine2}` ||
label ||
"Feature card"
}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
{cardContent}
</button>
);
}
// Default render as a div
return (
<div
className="block"
aria-label={
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
}
>
{cardContent}
</div>
);
},
);
MiniCard.displayName = "MiniCard";
export default MiniCard;
@@ -0,0 +1,97 @@
"use client";
import { memo, useMemo } from "react";
import MiniCardView from "./MiniCard.view";
import type { MiniCardProps } from "./MiniCard.types";
const MiniCardContainer = memo<MiniCardProps>(
({
children,
className = "",
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
panelContent,
label,
labelLine1,
labelLine2,
onClick,
href,
ariaLabel,
}) => {
// Compute aria-label
const computedAriaLabel = useMemo(
() =>
ariaLabel ||
(labelLine1 && labelLine2
? `${labelLine1} ${labelLine2}`
: label || "Feature card"),
[ariaLabel, labelLine1, labelLine2, label],
);
// Determine wrapper element and props
const { wrapperElement, wrapperProps } = useMemo(() => {
const baseProps = {
"aria-label": computedAriaLabel,
};
if (href) {
return {
wrapperElement: "a" as const,
wrapperProps: {
...baseProps,
href,
className:
"block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]",
tabIndex: 0,
},
};
}
if (onClick) {
return {
wrapperElement: "button" as const,
wrapperProps: {
...baseProps,
onClick,
className:
"block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]",
tabIndex: 0,
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
},
},
};
}
return {
wrapperElement: "div" as const,
wrapperProps: {
...baseProps,
className: "block",
},
};
}, [href, onClick, computedAriaLabel]);
return (
<MiniCardView
className={className}
backgroundColor={backgroundColor}
panelContent={panelContent}
label={label}
labelLine1={labelLine1}
labelLine2={labelLine2}
computedAriaLabel={computedAriaLabel}
wrapperElement={wrapperElement}
wrapperProps={wrapperProps}
>
{children}
</MiniCardView>
);
},
);
MiniCardContainer.displayName = "MiniCard";
export default MiniCardContainer;
+27
View File
@@ -0,0 +1,27 @@
export interface MiniCardProps {
children?: React.ReactNode;
className?: string;
backgroundColor?: string;
panelContent?: string;
label?: string;
labelLine1?: string;
labelLine2?: string;
onClick?: () => void;
href?: string;
ariaLabel?: string;
}
export interface MiniCardViewProps {
children?: React.ReactNode;
className: string;
backgroundColor: string;
panelContent?: string;
label?: string;
labelLine1?: string;
labelLine2?: string;
computedAriaLabel: string;
wrapperElement: "a" | "button" | "div";
wrapperProps: React.AnchorHTMLAttributes<HTMLAnchorElement> &
React.ButtonHTMLAttributes<HTMLButtonElement> &
React.HTMLAttributes<HTMLDivElement>;
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { memo } from "react";
import Image from "next/image";
import type { MiniCardViewProps } from "./MiniCard.types";
function MiniCardView({
children,
className,
backgroundColor,
panelContent,
label,
labelLine1,
labelLine2,
computedAriaLabel,
wrapperElement,
wrapperProps,
}: MiniCardViewProps) {
const cardContentElement = (
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
{/* Top part - Inner panel */}
<div
className={`flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`}
>
{/* Content for the inner panel */}
{panelContent && (
<div className="flex items-center justify-center w-full h-full">
<Image
src={panelContent}
alt={computedAriaLabel}
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
width={58}
height={58}
sizes="(max-width: 768px) 50vw, 25vw"
loading="lazy"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
/>
</div>
)}
{children}
</div>
{/* Bottom part - Text container */}
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
{labelLine1 && labelLine2 ? (
<>
<div>{labelLine1}</div>
<div>{labelLine2}</div>
<div>&nbsp;</div>
</>
) : (
label
)}
</div>
</div>
);
if (wrapperElement === "a") {
return (
<a {...(wrapperProps as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
{cardContentElement}
</a>
);
}
if (wrapperElement === "button") {
return (
<button
{...(wrapperProps as React.ButtonHTMLAttributes<HTMLButtonElement>)}
>
{cardContentElement}
</button>
);
}
return (
<div {...(wrapperProps as React.HTMLAttributes<HTMLDivElement>)}>
{cardContentElement}
</div>
);
}
MiniCardView.displayName = "MiniCardView";
export default memo(MiniCardView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./MiniCard.container";
export type { MiniCardProps } from "./MiniCard.types";
@@ -1,15 +1,10 @@
"use client";
import { memo } from "react"; import { memo } from "react";
import NavigationItemView from "./NavigationItem.view";
import type { NavigationItemProps } from "./NavigationItem.types";
interface NavigationItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> { const NavigationItemContainer = memo<NavigationItemProps>(
href?: string;
children?: React.ReactNode;
variant?: "default";
size?: "default" | "xsmall";
className?: string;
disabled?: boolean;
}
const NavigationItem = memo<NavigationItemProps>(
({ ({
href = "#", href = "#",
children, children,
@@ -50,22 +45,20 @@ const NavigationItem = memo<NavigationItemProps>(
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`; const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`;
if (disabled) {
return (
<span className={combinedStyles} {...props}>
{children}
</span>
);
}
return ( return (
<a href={href} className={combinedStyles} {...props}> <NavigationItemView
href={href}
disabled={disabled}
className={className}
combinedStyles={combinedStyles}
{...props}
>
{children} {children}
</a> </NavigationItemView>
); );
}, },
); );
NavigationItem.displayName = "NavigationItem"; NavigationItemContainer.displayName = "NavigationItem";
export default NavigationItem; export default NavigationItemContainer;
@@ -0,0 +1,17 @@
export interface NavigationItemProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href?: string;
children?: React.ReactNode;
variant?: "default";
size?: "default" | "xsmall";
className?: string;
disabled?: boolean;
}
export interface NavigationItemViewProps {
href: string;
children?: React.ReactNode;
disabled: boolean;
className: string;
combinedStyles: string;
}
@@ -0,0 +1,28 @@
import { memo } from "react";
import type { NavigationItemViewProps } from "./NavigationItem.types";
function NavigationItemView({
href,
children,
disabled,
combinedStyles,
...props
}: NavigationItemViewProps) {
if (disabled) {
return (
<span className={combinedStyles} {...props}>
{children}
</span>
);
}
return (
<a href={href} className={combinedStyles} {...props}>
{children}
</a>
);
}
NavigationItemView.displayName = "NavigationItemView";
export default memo(NavigationItemView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./NavigationItem.container";
export type { NavigationItemProps } from "./NavigationItem.types";
-284
View File
@@ -1,284 +0,0 @@
"use client";
import { useState, memo } from "react";
import Image from "next/image";
import QuoteDecor from "./QuoteDecor";
import { logger } from "../../lib/logger";
interface QuoteBlockProps {
variant?: "compact" | "standard" | "extended";
className?: string;
quote?: string;
author?: string;
source?: string;
avatarSrc?: string;
id?: string;
fallbackAvatarSrc?: string;
onError?: (_error: {
type: string;
message: string;
author?: string;
avatarSrc?: string;
error?: unknown;
quote?: boolean;
}) => void;
}
const QuoteBlock = memo<QuoteBlockProps>(
({
variant = "standard",
className = "",
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
author = "Jo Freeman",
source = "The Tyranny of Structurelessness",
avatarSrc = "/assets/Quote_Avatar.svg",
id,
fallbackAvatarSrc = "/assets/Quote_Avatar.svg",
onError,
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Variant configurations
interface VariantConfig {
container: string;
card: string;
gap: string;
avatarGap: string;
avatar: string;
quote: string;
author: string;
source: string;
showDecor: boolean;
}
const variants: Record<string, VariantConfig> = {
compact: {
container:
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
avatarGap: "gap-[var(--spacing-scale-012)]",
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
author: "text-[10px] leading-[120%] md:text-[12px]",
source: "text-[10px] leading-[120%] md:text-[12px]",
showDecor: false,
},
standard: {
container:
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
avatarGap:
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
avatar:
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
quote:
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
author:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
source:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
showDecor: true,
},
extended: {
container:
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
avatarGap:
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
avatar:
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
quote:
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
author:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
source:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
showDecor: true,
},
};
const config = variants[variant] || variants.standard;
// Use provided ID or generate a stable one based on content
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
const quoteId = `${baseId}-content`;
const authorId = `${baseId}-author`;
// Error handling functions
const handleImageError = (error: unknown) => {
logger.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`,
error,
);
setImageError(true);
setImageLoading(false);
// Call error callback if provided
if (onError) {
onError({
type: "image_load_error",
message: `Failed to load avatar for ${author}`,
author,
avatarSrc,
error,
});
}
};
const handleImageLoad = () => {
setImageLoading(false);
setImageError(false);
};
// Validate required props
if (!quote || !author) {
logger.error("QuoteBlock: Missing required props (quote or author)");
if (onError) {
onError({
type: "missing_props",
message: "QuoteBlock requires quote and author props",
quote: !!quote,
author,
});
}
return null; // Don't render if missing required props
}
// Determine which avatar to use
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
return (
<section
className={`${config.container} ${className}`}
aria-labelledby={quoteId}
role="region"
>
<div
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
>
{/* Background with noise texture */}
<div
className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
style={{
filter:
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')',
}}
/>
{/* DECORATIONS (behind content) */}
{config.showDecor && (
<QuoteDecor
className="pointer-events-none absolute z-0
left-0 top-0
w-full h-full"
aria-hidden="true"
/>
)}
<div className={`flex flex-col ${config.gap} relative z-10`}>
<div className={`flex flex-col ${config.avatarGap}`}>
{/* Avatar with error handling */}
<div className="relative">
{!imageError ? (
<Image
src={currentAvatarSrc}
alt={`Portrait of ${author}`}
width={64}
height={64}
className={`filter sepia ${
config.avatar
} transition-opacity duration-300 ${
imageLoading ? "opacity-0" : "opacity-100"
}`}
loading="lazy"
onError={handleImageError}
onLoad={handleImageLoad}
/>
) : null}
{/* Loading state */}
{imageLoading && !imageError && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
/>
)}
{/* Error state - show initials */}
{imageError && (
<div
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
>
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
{author
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()}
</span>
</div>
)}
</div>
<blockquote
id={quoteId}
aria-labelledby={authorId}
className="relative"
>
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-bricolage-grotesque font-normal",
config.quote,
"text-[var(--color-content-inverse-primary)]",
// give space for the hanging open-quote so it's not clipped:
"pl-[0.6em] -indent-[0.6em]",
// inject quotes
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
// lock quote glyphs to your display face
"before:[font-family:var(--font-bricolage-grotesque)]",
"after:[font-family:var(--font-bricolage-grotesque)]",
].join(" ")}
>
{quote}
</p>
</blockquote>
</div>
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
<cite
id={authorId}
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
>
{author}
</cite>
{source && (
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-inter font-normal",
config.source,
"text-[var(--color-content-inverse-primary)] uppercase",
"pl-[0.6em] -indent-[0.6em]",
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
].join(" ")}
>
{source}
</p>
)}
</footer>
</div>
</div>
</section>
);
},
);
QuoteBlock.displayName = "QuoteBlock";
export default QuoteBlock;
@@ -0,0 +1,141 @@
"use client";
import { memo, useState, useId } from "react";
import { logger } from "../../lib/logger";
import QuoteBlockView from "./QuoteBlock.view";
import type { QuoteBlockProps } from "./QuoteBlock.types";
const QuoteBlockContainer = memo<QuoteBlockProps>(
({
quote,
author,
authorRole,
authorImage,
variant = "default",
className = "",
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const quoteId = useId();
// Variant configuration
const variantConfig = {
default: {
container:
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-064)]",
quote: "text-[var(--color-content-default-primary)]",
author: "text-[var(--color-content-default-secondary)]",
authorRole: "text-[var(--color-content-default-tertiary)]",
},
inverse: {
container:
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-064)] bg-[var(--color-surface-inverse-primary)]",
quote: "text-[var(--color-content-inverse-primary)]",
author: "text-[var(--color-content-inverse-secondary)]",
authorRole: "text-[var(--color-content-inverse-tertiary)]",
},
compact: {
container:
"py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]",
quote: "text-[var(--color-content-default-primary)]",
author: "text-[var(--color-content-default-secondary)]",
authorRole: "text-[var(--color-content-default-tertiary)]",
},
};
const config = variantConfig[variant];
const containerClasses = `
relative
flex
flex-col
gap-[var(--spacing-scale-024)]
${config.container}
`
.trim()
.replace(/\s+/g, " ");
const quoteClasses = `
text-[18px]
md:text-[24px]
leading-[28px]
md:leading-[36px]
font-medium
${config.quote}
`
.trim()
.replace(/\s+/g, " ");
const authorClasses = `
text-[14px]
md:text-[16px]
leading-[20px]
md:leading-[24px]
font-semibold
not-italic
${config.author}
`
.trim()
.replace(/\s+/g, " ");
const authorRoleClasses = `
text-[12px]
md:text-[14px]
leading-[16px]
md:leading-[20px]
font-normal
${config.authorRole}
`
.trim()
.replace(/\s+/g, " ");
const imageContainerClasses = `
w-[var(--measures-sizing-048)]
h-[var(--measures-sizing-048)]
rounded-full
overflow-hidden
shrink-0
`
.trim()
.replace(/\s+/g, " ");
const handleImageLoad = () => {
setImageLoading(false);
setImageError(false);
};
const handleImageError = () => {
setImageError(true);
setImageLoading(false);
logger.warn("QuoteBlock: Failed to load author image", {
authorImage,
author,
});
};
return (
<QuoteBlockView
quoteId={quoteId}
quote={quote}
author={author}
authorRole={authorRole}
authorImage={authorImage}
variant={variant}
className={className}
imageError={imageError}
imageLoading={imageLoading}
containerClasses={containerClasses}
quoteClasses={quoteClasses}
authorClasses={authorClasses}
authorRoleClasses={authorRoleClasses}
imageContainerClasses={imageContainerClasses}
onImageLoad={handleImageLoad}
onImageError={handleImageError}
/>
);
},
);
QuoteBlockContainer.displayName = "QuoteBlock";
export default QuoteBlockContainer;
@@ -0,0 +1,27 @@
export interface QuoteBlockProps {
quote: string;
author?: string;
authorRole?: string;
authorImage?: string;
variant?: "default" | "inverse" | "compact";
className?: string;
}
export interface QuoteBlockViewProps {
quoteId: string;
quote: string;
author?: string;
authorRole?: string;
authorImage?: string;
variant: "default" | "inverse" | "compact";
className: string;
imageError: boolean;
imageLoading: boolean;
containerClasses: string;
quoteClasses: string;
authorClasses: string;
authorRoleClasses: string;
imageContainerClasses: string;
onImageLoad: () => void;
onImageError: () => void;
}
@@ -0,0 +1,67 @@
import { memo } from "react";
import Image from "next/image";
import QuoteDecor from "../QuoteDecor";
import type { QuoteBlockViewProps } from "./QuoteBlock.types";
function QuoteBlockView({
quoteId,
quote,
author,
authorRole,
authorImage,
variant,
className,
imageError,
imageLoading,
containerClasses,
quoteClasses,
authorClasses,
authorRoleClasses,
imageContainerClasses,
onImageLoad,
onImageError,
}: QuoteBlockViewProps) {
return (
<blockquote
id={quoteId}
className={`${containerClasses} ${className}`}
aria-label={author ? `Quote by ${author}` : "Quote"}
>
<QuoteDecor variant={variant} />
<div className="flex flex-col gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]">
<p className={quoteClasses}>{quote}</p>
{(author || authorRole) && (
<div className="flex items-center gap-[var(--spacing-scale-016)]">
{authorImage && !imageError && (
<div className={imageContainerClasses}>
{imageLoading ? (
<div className="w-full h-full bg-[var(--color-surface-default-secondary)] animate-pulse rounded-full" />
) : (
<Image
src={authorImage}
alt={author ? `${author}'s profile picture` : "Author"}
width={48}
height={48}
className="rounded-full object-cover"
onLoad={onImageLoad}
onError={onImageError}
/>
)}
</div>
)}
<div className="flex flex-col">
{author && <cite className={authorClasses}>{author}</cite>}
{authorRole && (
<span className={authorRoleClasses}>{authorRole}</span>
)}
</div>
</div>
)}
</div>
</blockquote>
);
}
QuoteBlockView.displayName = "QuoteBlockView";
export default memo(QuoteBlockView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./QuoteBlock.container";
export type { QuoteBlockProps } from "./QuoteBlock.types";
@@ -1,22 +1,10 @@
"use client"; "use client";
import { memo, useCallback, useId } from "react"; import { memo, useCallback, useId } from "react";
import { RadioButtonView } from "./RadioButton.view";
import type { RadioButtonProps } from "./RadioButton.types";
interface RadioButtonProps { const RadioButtonContainer = ({
checked?: boolean;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
label?: string;
onChange?: (_data: { checked: boolean; value?: string }) => void;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
className?: string;
}
const RadioButton = ({
checked = false, checked = false,
mode = "standard", mode = "standard",
state = "default", state = "default",
@@ -91,67 +79,39 @@ const RadioButton = ({
[disabled, onChange, checked, value], [disabled, onChange, checked, value],
); );
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleToggle(e);
}
};
return ( return (
<label <RadioButtonView
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${ radioId={radioId}
disabled ? "opacity-60 cursor-not-allowed" : "" checked={checked}
} ${className}`} mode={mode}
onMouseDown={(e) => e.preventDefault()} state={state}
onClick={handleToggle} disabled={disabled}
> label={label}
<span name={name}
onKeyDown={(e) => { value={value}
if (e.key === " " || e.key === "Enter") { ariaLabel={ariaLabel}
e.preventDefault(); className={className}
handleToggle(e); combinedBoxStyles={combinedBoxStyles}
} defaultOutlineClass={defaultOutlineClass}
}} conditionalHoverOutlineClass={conditionalHoverOutlineClass}
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`} conditionalFocusClass={conditionalFocusClass}
style={{ backgroundWhenChecked={backgroundWhenChecked}
backgroundColor: backgroundWhenChecked, dotColor={dotColor}
}} labelColor={labelColor}
tabIndex={0} onToggle={handleToggle}
role="radio" onKeyDown={handleKeyDown}
aria-checked={checked} {...props}
{...(disabled && { "aria-disabled": true })} />
{...(ariaLabel && { "aria-label": ariaLabel })}
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
id={radioId}
>
{/* Radio dot */}
<div
className="w-[16px] h-[16px] rounded-full transition-all duration-200"
style={{
backgroundColor: dotColor,
}}
/>
</span>
{label && (
<span
id={`${radioId}-label`}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
{label}
</span>
)}
{/* Hidden input for form submission */}
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => {}}
disabled={disabled}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
{...props}
/>
</label>
); );
}; };
RadioButton.displayName = "RadioButton"; RadioButtonContainer.displayName = "RadioButton";
export default memo(RadioButton); export default memo(RadioButtonContainer);
@@ -0,0 +1,35 @@
export interface RadioButtonProps {
checked?: boolean;
mode?: "standard" | "inverse";
state?: "default" | "hover" | "focus";
disabled?: boolean;
label?: string;
onChange?: (_data: { checked: boolean; value?: string }) => void;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
className?: string;
}
export interface RadioButtonViewProps {
radioId: string;
checked: boolean;
mode: "standard" | "inverse";
state: "default" | "hover" | "focus";
disabled: boolean;
label?: string;
name?: string;
value?: string;
ariaLabel?: string;
className: string;
combinedBoxStyles: string;
defaultOutlineClass: string;
conditionalHoverOutlineClass: string;
conditionalFocusClass: string;
backgroundWhenChecked: string;
dotColor: string;
labelColor: string;
onToggle: (e: React.MouseEvent | React.KeyboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLSpanElement>) => void;
}
@@ -0,0 +1,77 @@
import type { RadioButtonViewProps } from "./RadioButton.types";
export function RadioButtonView({
radioId,
checked,
disabled,
label,
name,
value,
ariaLabel,
className,
combinedBoxStyles,
defaultOutlineClass,
conditionalHoverOutlineClass,
conditionalFocusClass,
backgroundWhenChecked,
dotColor,
labelColor,
onToggle,
onKeyDown,
...props
}: RadioButtonViewProps) {
return (
<label
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
disabled ? "opacity-60 cursor-not-allowed" : ""
} ${className}`}
onMouseDown={(e) => e.preventDefault()}
onClick={onToggle}
>
<span
onKeyDown={onKeyDown}
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
style={{
backgroundColor: backgroundWhenChecked,
}}
tabIndex={0}
role="radio"
aria-checked={checked}
{...(disabled && { "aria-disabled": true })}
{...(ariaLabel && { "aria-label": ariaLabel })}
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
id={radioId}
{...props}
>
{/* Radio dot */}
<div
className="w-[16px] h-[16px] rounded-full transition-all duration-200"
style={{
backgroundColor: dotColor,
}}
/>
</span>
{label && (
<span
id={`${radioId}-label`}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
{label}
</span>
)}
{/* Hidden input for form submission */}
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => {}}
disabled={disabled}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
/>
</label>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./RadioButton.container";
export type { RadioButtonProps } from "./RadioButton.types";
@@ -1,25 +1,14 @@
"use client"; "use client";
import { forwardRef, memo, useCallback } from "react"; import { forwardRef, memo, useCallback } from "react";
import { SelectOptionView } from "./SelectOption.view";
import type { SelectOptionProps } from "./SelectOption.types";
interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> { const SelectOptionContainer = forwardRef<HTMLDivElement, SelectOptionProps>(
children?: React.ReactNode;
selected?: boolean;
hasSubmenu?: boolean;
disabled?: boolean;
className?: string;
onClick?: (
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
) => void;
size?: "small" | "medium" | "large";
}
const ContextMenuItem = forwardRef<HTMLDivElement, ContextMenuItemProps>(
( (
{ {
children, children,
selected = false, selected = false,
hasSubmenu = false,
disabled = false, disabled = false,
className = "", className = "",
onClick, onClick,
@@ -85,55 +74,22 @@ const ContextMenuItem = forwardRef<HTMLDivElement, ContextMenuItemProps>(
); );
return ( return (
<div <SelectOptionView
ref={ref} ref={ref}
className={itemClasses} selected={selected}
role="menuitem" disabled={disabled}
tabIndex={disabled ? -1 : 0} className={className}
aria-current={selected ? "true" : undefined} itemClasses={itemClasses}
aria-disabled={disabled} handleClick={handleClick}
onClick={handleClick} handleKeyDown={handleKeyDown}
onKeyDown={handleKeyDown}
{...props} {...props}
> >
<div className="flex items-center gap-[8px]"> {children}
{selected && ( </SelectOptionView>
<svg
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
<span>{children}</span>
</div>
{hasSubmenu && (
<svg
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</div>
); );
}, },
); );
ContextMenuItem.displayName = "ContextMenuItem"; SelectOptionContainer.displayName = "SelectOption";
export default memo(ContextMenuItem); export default memo(SelectOptionContainer);
@@ -0,0 +1,20 @@
export interface SelectOptionProps {
children?: React.ReactNode;
selected?: boolean;
disabled?: boolean;
className?: string;
onClick?: (
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
) => void;
size?: "small" | "medium" | "large";
}
export interface SelectOptionViewProps {
children?: React.ReactNode;
selected: boolean;
disabled: boolean;
className: string;
itemClasses: string;
handleClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
}
@@ -0,0 +1,52 @@
import { forwardRef } from "react";
import type { SelectOptionViewProps } from "./SelectOption.types";
export const SelectOptionView = forwardRef<HTMLDivElement, SelectOptionViewProps>(
(
{
children,
selected,
disabled,
itemClasses,
handleClick,
handleKeyDown,
...props
},
ref,
) => {
return (
<div
ref={ref}
className={itemClasses}
role="option"
tabIndex={disabled ? -1 : 0}
aria-selected={selected}
aria-disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...props}
>
<div className="flex items-center gap-[8px]">
{selected && (
<svg
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
<span>{children}</span>
</div>
</div>
);
},
);
SelectOptionView.displayName = "SelectOptionView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./SelectOption.container";
export type { SelectOptionProps } from "./SelectOption.types";
@@ -1,23 +1,10 @@
"use client";
import { memo, useCallback, useId, forwardRef } from "react"; import { memo, useCallback, useId, forwardRef } from "react";
import { SwitchView } from "./Switch.view";
import type { SwitchProps } from "./Switch.types";
interface SwitchProps extends Omit< const SwitchContainer = memo(
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
checked?: boolean;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
state?: "default" | "hover" | "focus";
label?: string;
className?: string;
}
const Switch = memo(
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => { forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
const { const {
checked = false, checked = false,
@@ -150,31 +137,27 @@ const Switch = memo(
.replace(/\s+/g, " "); .replace(/\s+/g, " ");
return ( return (
<div className="flex items-center"> <SwitchView
<button ref={ref}
ref={ref} switchId={switchId}
id={switchId} checked={checked}
type="button" state={state}
role="switch" label={label}
aria-checked={checked} className={className}
aria-label={label || "Toggle switch"} switchClasses={switchClasses}
onClick={handleClick} trackClasses={trackClasses}
onKeyDown={handleKeyDown} thumbClasses={thumbClasses}
onFocus={handleFocus} labelClasses={labelClasses}
onBlur={handleBlur} onClick={handleClick}
className={switchClasses} onKeyDown={handleKeyDown}
{...rest} onFocus={handleFocus}
> onBlur={handleBlur}
<div className={trackClasses}> {...rest}
<div className={thumbClasses} /> />
</div>
</button>
{label && <span className={labelClasses}>{label}</span>}
</div>
); );
}), }),
); );
Switch.displayName = "Switch"; SwitchContainer.displayName = "Switch";
export default Switch; export default SwitchContainer;
+30
View File
@@ -0,0 +1,30 @@
export interface SwitchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked?: boolean;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
state?: "default" | "hover" | "focus";
label?: string;
className?: string;
}
export interface SwitchViewProps {
switchId: string;
checked: boolean;
state: "default" | "hover" | "focus";
label?: string;
className: string;
switchClasses: string;
trackClasses: string;
thumbClasses: string;
labelClasses: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (e: React.FocusEvent<HTMLButtonElement>) => void;
}
+48
View File
@@ -0,0 +1,48 @@
import { forwardRef } from "react";
import type { SwitchViewProps } from "./Switch.types";
export const SwitchView = forwardRef<HTMLButtonElement, SwitchViewProps>(
(
{
switchId,
checked,
label,
switchClasses,
trackClasses,
thumbClasses,
labelClasses,
onClick,
onKeyDown,
onFocus,
onBlur,
...rest
},
ref,
) => {
return (
<div className="flex items-center">
<button
ref={ref}
id={switchId}
type="button"
role="switch"
aria-checked={checked}
aria-label={label || "Toggle switch"}
onClick={onClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
className={switchClasses}
{...rest}
>
<div className={trackClasses}>
<div className={thumbClasses} />
</div>
</button>
{label && <span className={labelClasses}>{label}</span>}
</div>
);
},
);
SwitchView.displayName = "SwitchView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Switch.container";
export type { SwitchProps } from "./Switch.types";
@@ -1,28 +1,11 @@
"use client"; "use client";
import { memo, forwardRef } from "react"; import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../hooks"; import { useComponentId, useFormField } from "../../hooks";
import { TextAreaView } from "./TextArea.view";
import type { TextAreaProps } from "./TextArea.types";
interface TextAreaProps extends Omit< const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
label?: string;
placeholder?: string;
value?: string;
onChange?: (_e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onFocus?: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
className?: string;
rows?: number;
}
const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
( (
{ {
size = "medium", size = "medium",
@@ -163,40 +146,35 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
}); });
return ( return (
<div className={containerClasses}> <TextAreaView
{label && ( ref={ref}
<label textareaId={textareaId}
id={labelId} labelId={labelId}
htmlFor={textareaId} size={size}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`} labelVariant={labelVariant}
> state={state}
{label} disabled={disabled}
</label> error={error}
)} label={label}
<div className={disabled ? "opacity-40" : ""}> placeholder={placeholder}
<textarea value={value}
ref={ref} name={name}
id={textareaId} className={className}
name={name} rows={rows}
value={value} containerClasses={containerClasses}
placeholder={placeholder} labelClasses={labelClasses}
onChange={handleChange} textareaClasses={textareaClasses}
onFocus={handleFocus} borderRadius={currentSize.radius}
onBlur={handleBlur} handleChange={handleChange}
disabled={disabled} handleFocus={handleFocus}
rows={rows} handleBlur={handleBlur}
className={textareaClasses} aria-invalid={error}
style={{ borderRadius: currentSize.radius }} {...props}
aria-disabled={disabled} />
aria-invalid={error}
{...props}
/>
</div>
</div>
); );
}, },
); );
TextArea.displayName = "TextArea"; TextAreaContainer.displayName = "TextArea";
export default memo(TextArea); export default memo(TextAreaContainer);
+39
View File
@@ -0,0 +1,39 @@
export interface TextAreaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "size" | "onChange" | "onFocus" | "onBlur"> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
disabled?: boolean;
error?: boolean;
label?: string;
placeholder?: string;
value?: string;
onChange?: (_e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onFocus?: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
className?: string;
rows?: number;
}
export interface TextAreaViewProps {
textareaId: string;
labelId: string;
size: "small" | "medium" | "large";
labelVariant: "default" | "horizontal";
state: "default" | "active" | "hover" | "focus";
disabled: boolean;
error: boolean;
label?: string;
placeholder?: string;
value?: string;
name?: string;
className: string;
rows?: number;
containerClasses: string;
labelClasses: string;
textareaClasses: string;
borderRadius: string;
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleFocus: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
}
+62
View File
@@ -0,0 +1,62 @@
import { forwardRef } from "react";
import type { TextAreaViewProps } from "./TextArea.types";
export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
(
{
textareaId,
labelId,
label,
placeholder,
value,
name,
disabled,
className,
rows,
containerClasses,
labelClasses,
textareaClasses,
borderRadius,
handleChange,
handleFocus,
handleBlur,
...props
},
ref,
) => {
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={textareaId}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className={disabled ? "opacity-40" : ""}>
<textarea
ref={ref}
id={textareaId}
name={name}
value={value}
placeholder={placeholder}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
rows={rows}
className={textareaClasses}
style={{ borderRadius }}
aria-disabled={disabled}
aria-invalid={props["aria-invalid"]}
{...props}
/>
</div>
</div>
);
},
);
TextAreaView.displayName = "TextAreaView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./TextArea.container";
export type { TextAreaProps } from "./TextArea.types";
@@ -1,28 +1,10 @@
"use client";
import { memo, useCallback, useId, forwardRef } from "react"; import { memo, useCallback, useId, forwardRef } from "react";
import { ToggleView } from "./Toggle.view";
import type { ToggleProps } from "./Toggle.types";
interface ToggleProps extends Omit< const ToggleContainer = forwardRef<HTMLButtonElement, ToggleProps>(
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
label?: string;
checked?: boolean;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
disabled?: boolean;
state?: "default" | "hover" | "focus";
showIcon?: boolean;
showText?: boolean;
icon?: string;
text?: string;
className?: string;
}
const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
( (
{ {
label, label,
@@ -183,41 +165,32 @@ const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
); );
return ( return (
<div className={containerClasses}> <ToggleView
{label && ( ref={ref}
<label toggleId={toggleId}
id={labelId} labelId={labelId}
htmlFor={toggleId} checked={checked}
className={`${labelClasses} text-[var(--color-content-default-secondary)]`} disabled={disabled}
> state={state}
{label} label={label}
</label> showIcon={showIcon}
)} showText={showText}
<div className={disabled ? "opacity-40" : ""}> icon={icon}
<button text={text}
ref={ref} className={className}
id={toggleId} containerClasses={containerClasses}
type="button" labelClasses={labelClasses}
role="switch" toggleClasses={toggleClasses}
aria-checked={checked} onClick={handleChange}
aria-labelledby={label ? labelId : undefined} onKeyDown={handleKeyDown}
disabled={disabled} onFocus={handleFocus}
onClick={handleChange} onBlur={handleBlur}
onKeyDown={handleKeyDown} {...props}
onFocus={handleFocus} />
onBlur={handleBlur}
className={toggleClasses}
{...props}
>
{showIcon && <span className="italic">{icon}</span>}
{showText && <span>{text}</span>}
</button>
</div>
</div>
); );
}, },
); );
Toggle.displayName = "Toggle"; ToggleContainer.displayName = "Toggle";
export default memo(Toggle); export default memo(ToggleContainer);
+44
View File
@@ -0,0 +1,44 @@
export interface ToggleProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
label?: string;
checked?: boolean;
onChange?: (
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
disabled?: boolean;
state?: "default" | "hover" | "focus";
showIcon?: boolean;
showText?: boolean;
icon?: string;
text?: string;
className?: string;
}
export interface ToggleViewProps {
toggleId: string;
labelId: string;
checked: boolean;
disabled: boolean;
state: "default" | "hover" | "focus";
label?: string;
showIcon: boolean;
showText: boolean;
icon: string;
text: string;
className: string;
containerClasses: string;
labelClasses: string;
toggleClasses: string;
onClick: (
e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (e: React.FocusEvent<HTMLButtonElement>) => void;
}
+63
View File
@@ -0,0 +1,63 @@
import { forwardRef } from "react";
import type { ToggleViewProps } from "./Toggle.types";
export const ToggleView = forwardRef<HTMLButtonElement, ToggleViewProps>(
(
{
toggleId,
labelId,
checked,
disabled,
label,
showIcon,
showText,
icon,
text,
containerClasses,
labelClasses,
toggleClasses,
onClick,
onKeyDown,
onFocus,
onBlur,
...rest
},
ref,
) => {
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={toggleId}
className={`${labelClasses} text-[var(--color-content-default-secondary)]`}
>
{label}
</label>
)}
<div className={disabled ? "opacity-40" : ""}>
<button
ref={ref}
id={toggleId}
type="button"
role="switch"
aria-checked={checked}
aria-labelledby={label ? labelId : undefined}
disabled={disabled}
onClick={onClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
className={toggleClasses}
{...rest}
>
{showIcon && <span className="italic">{icon}</span>}
{showText && <span>{text}</span>}
</button>
</div>
</div>
);
},
);
ToggleView.displayName = "ToggleView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Toggle.container";
export type { ToggleProps } from "./Toggle.types";