Refactor Components #29
@@ -1,36 +1,15 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
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>(
|
||||
const CheckboxContainer = memo<CheckboxProps>(
|
||||
({
|
||||
checked = false,
|
||||
mode = "standard", // "standard" | "inverse"
|
||||
state = "default", // "default" | "hover" | "focus"
|
||||
mode = "standard",
|
||||
state = "default",
|
||||
disabled = false,
|
||||
label,
|
||||
className = "",
|
||||
@@ -109,72 +88,41 @@ const Checkbox = memo<CheckboxProps>(
|
||||
...props,
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleToggle(e);
|
||||
}
|
||||
};
|
||||
|
||||
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={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleToggle(e);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
<CheckboxView
|
||||
checkboxId={checkboxId}
|
||||
labelId={labelId}
|
||||
checked={checked}
|
||||
mode={mode}
|
||||
state={state}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
name={name}
|
||||
value={value}
|
||||
ariaLabel={ariaLabel}
|
||||
className={className}
|
||||
combinedBoxStyles={combinedBoxStyles}
|
||||
defaultOutlineClass={defaultOutlineClass}
|
||||
conditionalHoverOutlineClass={conditionalHoverOutlineClass}
|
||||
conditionalFocusClass={conditionalFocusClass}
|
||||
backgroundWhenChecked={backgroundWhenChecked}
|
||||
checkGlyphColor={checkGlyphColor}
|
||||
labelColor={labelColor}
|
||||
accessibilityProps={accessibilityProps}
|
||||
onToggle={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Checkbox.displayName = "Checkbox";
|
||||
CheckboxContainer.displayName = "Checkbox";
|
||||
|
||||
export default Checkbox;
|
||||
export default CheckboxContainer;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Checkbox.container";
|
||||
export type { CheckboxProps } from "./Checkbox.types";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
+18
-126
@@ -1,39 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Button from "./Button";
|
||||
import { getAssetPath } from "../../lib/assetUtils";
|
||||
import ContentLockupView from "./ContentLockup.view";
|
||||
import type { ContentLockupProps, VariantStyle } from "./ContentLockup.types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface VariantStyle {
|
||||
container: string;
|
||||
textContainer: string;
|
||||
titleGroup: string;
|
||||
titleContainer: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description?: string;
|
||||
shape: string;
|
||||
}
|
||||
|
||||
const ContentLockup = memo<ContentLockupProps>(
|
||||
const ContentLockupContainer = memo<ContentLockupProps>(
|
||||
({
|
||||
title,
|
||||
subtitle,
|
||||
@@ -125,102 +96,23 @@ const ContentLockup = memo<ContentLockupProps>(
|
||||
const styles = variantStyles[variant] || variantStyles.hero;
|
||||
|
||||
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
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
description={description}
|
||||
ctaText={ctaText}
|
||||
buttonClassName={buttonClassName}
|
||||
variant={variant}
|
||||
linkText={linkText}
|
||||
linkHref={linkHref}
|
||||
alignment={alignment}
|
||||
titleId={titleId}
|
||||
styles={styles}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./ContentLockup.container";
|
||||
export type { ContentLockupProps } from "./ContentLockup.types";
|
||||
@@ -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";
|
||||
+19
-41
@@ -1,23 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, memo, useCallback } from "react";
|
||||
import { ContextMenuItemView } from "./ContextMenuItem.view";
|
||||
import type { ContextMenuItemProps } from "./ContextMenuItem.types";
|
||||
|
||||
interface SelectOptionProps {
|
||||
children?: React.ReactNode;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: (
|
||||
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
const SelectOption = forwardRef<HTMLDivElement, SelectOptionProps>(
|
||||
const ContextMenuItemContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
ContextMenuItemProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
selected = false,
|
||||
hasSubmenu = false,
|
||||
disabled = false,
|
||||
className = "",
|
||||
onClick,
|
||||
@@ -83,40 +78,23 @@ const SelectOption = forwardRef<HTMLDivElement, SelectOptionProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<ContextMenuItemView
|
||||
ref={ref}
|
||||
className={itemClasses}
|
||||
role="option"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-selected={selected}
|
||||
aria-disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
selected={selected}
|
||||
hasSubmenu={hasSubmenu}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
itemClasses={itemClasses}
|
||||
handleClick={handleClick}
|
||||
handleKeyDown={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>
|
||||
{children}
|
||||
</ContextMenuItemView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./ContextMenuItem.container";
|
||||
export type { ContextMenuItemProps } from "./ContextMenuItem.types";
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./HomeHeader.container";
|
||||
export type { HomeHeaderProps } from "./HomeHeader.types";
|
||||
@@ -1,27 +1,11 @@
|
||||
"use client";
|
||||
|
||||
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<
|
||||
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>(
|
||||
const InputContainer = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
size = "medium",
|
||||
@@ -159,38 +143,34 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
});
|
||||
|
||||
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: currentSize.radius }}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<InputView
|
||||
ref={ref}
|
||||
inputId={inputId}
|
||||
labelId={labelId}
|
||||
size={size}
|
||||
labelVariant={labelVariant}
|
||||
state={state}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
name={name}
|
||||
type={type}
|
||||
className={className}
|
||||
containerClasses={containerClasses}
|
||||
labelClasses={labelClasses}
|
||||
inputClasses={inputClasses}
|
||||
borderRadius={currentSize.radius}
|
||||
handleChange={handleChange}
|
||||
handleFocus={handleFocus}
|
||||
handleBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
InputContainer.displayName = "Input";
|
||||
|
||||
export default memo(Input);
|
||||
export default memo(InputContainer);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Input.container";
|
||||
export type { InputProps } from "./Input.types";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./LogoWall.container";
|
||||
export type { LogoWallProps } from "./LogoWall.types";
|
||||
+15
-34
@@ -1,27 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import MenuBarItemView from "./MenuBarItem.view";
|
||||
import type { MenuBarItemProps } from "./MenuBarItem.types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const MenuBarItem = memo<MenuBarItemProps>(
|
||||
const MenuBarItemContainer = memo<MenuBarItemProps>(
|
||||
({
|
||||
href = "#",
|
||||
children,
|
||||
@@ -166,22 +149,20 @@ const MenuBarItem = memo<MenuBarItemProps>(
|
||||
...props,
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={combinedStyles} {...accessibilityProps}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
||||
<MenuBarItemView
|
||||
href={href}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
combinedStyles={combinedStyles}
|
||||
accessibilityProps={accessibilityProps}
|
||||
>
|
||||
{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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./MenuBarItem.container";
|
||||
export type { MenuBarItemProps } from "./MenuBarItem.types";
|
||||
@@ -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> </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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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> </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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./MiniCard.container";
|
||||
export type { MiniCardProps } from "./MiniCard.types";
|
||||
+15
-22
@@ -1,15 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import NavigationItemView from "./NavigationItem.view";
|
||||
import type { NavigationItemProps } from "./NavigationItem.types";
|
||||
|
||||
interface NavigationItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
variant?: "default";
|
||||
size?: "default" | "xsmall";
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const NavigationItem = memo<NavigationItemProps>(
|
||||
const NavigationItemContainer = memo<NavigationItemProps>(
|
||||
({
|
||||
href = "#",
|
||||
children,
|
||||
@@ -50,22 +45,20 @@ const NavigationItem = memo<NavigationItemProps>(
|
||||
|
||||
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`;
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={combinedStyles} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} className={combinedStyles} {...props}>
|
||||
<NavigationItemView
|
||||
href={href}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
combinedStyles={combinedStyles}
|
||||
{...props}
|
||||
>
|
||||
{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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./NavigationItem.container";
|
||||
export type { NavigationItemProps } from "./NavigationItem.types";
|
||||
@@ -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="“"
|
||||
data-qclose="”"
|
||||
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="“"
|
||||
data-qclose="”"
|
||||
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);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./QuoteBlock.container";
|
||||
export type { QuoteBlockProps } from "./QuoteBlock.types";
|
||||
+34
-74
@@ -1,22 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useId } from "react";
|
||||
import { RadioButtonView } from "./RadioButton.view";
|
||||
import type { RadioButtonProps } from "./RadioButton.types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const RadioButton = ({
|
||||
const RadioButtonContainer = ({
|
||||
checked = false,
|
||||
mode = "standard",
|
||||
state = "default",
|
||||
@@ -91,67 +79,39 @@ const RadioButton = ({
|
||||
[disabled, onChange, checked, value],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleToggle(e);
|
||||
}
|
||||
};
|
||||
|
||||
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={handleToggle}
|
||||
>
|
||||
<span
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleToggle(e);
|
||||
}
|
||||
}}
|
||||
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}
|
||||
>
|
||||
{/* 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>
|
||||
<RadioButtonView
|
||||
radioId={radioId}
|
||||
checked={checked}
|
||||
mode={mode}
|
||||
state={state}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
name={name}
|
||||
value={value}
|
||||
ariaLabel={ariaLabel}
|
||||
className={className}
|
||||
combinedBoxStyles={combinedBoxStyles}
|
||||
defaultOutlineClass={defaultOutlineClass}
|
||||
conditionalHoverOutlineClass={conditionalHoverOutlineClass}
|
||||
conditionalFocusClass={conditionalFocusClass}
|
||||
backgroundWhenChecked={backgroundWhenChecked}
|
||||
dotColor={dotColor}
|
||||
labelColor={labelColor}
|
||||
onToggle={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./RadioButton.container";
|
||||
export type { RadioButtonProps } from "./RadioButton.types";
|
||||
+14
-58
@@ -1,25 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, memo, useCallback } from "react";
|
||||
import { SelectOptionView } from "./SelectOption.view";
|
||||
import type { SelectOptionProps } from "./SelectOption.types";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
const ContextMenuItem = forwardRef<HTMLDivElement, ContextMenuItemProps>(
|
||||
const SelectOptionContainer = forwardRef<HTMLDivElement, SelectOptionProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
selected = false,
|
||||
hasSubmenu = false,
|
||||
disabled = false,
|
||||
className = "",
|
||||
onClick,
|
||||
@@ -85,55 +74,22 @@ const ContextMenuItem = forwardRef<HTMLDivElement, ContextMenuItemProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<SelectOptionView
|
||||
ref={ref}
|
||||
className={itemClasses}
|
||||
role="menuitem"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
aria-disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
selected={selected}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
itemClasses={itemClasses}
|
||||
handleClick={handleClick}
|
||||
handleKeyDown={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>
|
||||
{children}
|
||||
</SelectOptionView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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";
|
||||
@@ -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 { SwitchView } from "./Switch.view";
|
||||
import type { SwitchProps } from "./Switch.types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const Switch = memo(
|
||||
const SwitchContainer = memo(
|
||||
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
|
||||
const {
|
||||
checked = false,
|
||||
@@ -150,31 +137,27 @@ const Switch = memo(
|
||||
.replace(/\s+/g, " ");
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
ref={ref}
|
||||
id={switchId}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label || "Toggle switch"}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={switchClasses}
|
||||
{...rest}
|
||||
>
|
||||
<div className={trackClasses}>
|
||||
<div className={thumbClasses} />
|
||||
</div>
|
||||
</button>
|
||||
{label && <span className={labelClasses}>{label}</span>}
|
||||
</div>
|
||||
<SwitchView
|
||||
ref={ref}
|
||||
switchId={switchId}
|
||||
checked={checked}
|
||||
state={state}
|
||||
label={label}
|
||||
className={className}
|
||||
switchClasses={switchClasses}
|
||||
trackClasses={trackClasses}
|
||||
thumbClasses={thumbClasses}
|
||||
labelClasses={labelClasses}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
Switch.displayName = "Switch";
|
||||
SwitchContainer.displayName = "Switch";
|
||||
|
||||
export default Switch;
|
||||
export default SwitchContainer;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Switch.container";
|
||||
export type { SwitchProps } from "./Switch.types";
|
||||
@@ -1,28 +1,11 @@
|
||||
"use client";
|
||||
|
||||
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<
|
||||
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>(
|
||||
const TextAreaContainer = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
(
|
||||
{
|
||||
size = "medium",
|
||||
@@ -163,40 +146,35 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
});
|
||||
|
||||
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: currentSize.radius }}
|
||||
aria-disabled={disabled}
|
||||
aria-invalid={error}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextAreaView
|
||||
ref={ref}
|
||||
textareaId={textareaId}
|
||||
labelId={labelId}
|
||||
size={size}
|
||||
labelVariant={labelVariant}
|
||||
state={state}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
name={name}
|
||||
className={className}
|
||||
rows={rows}
|
||||
containerClasses={containerClasses}
|
||||
labelClasses={labelClasses}
|
||||
textareaClasses={textareaClasses}
|
||||
borderRadius={currentSize.radius}
|
||||
handleChange={handleChange}
|
||||
handleFocus={handleFocus}
|
||||
handleBlur={handleBlur}
|
||||
aria-invalid={error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TextArea.displayName = "TextArea";
|
||||
TextAreaContainer.displayName = "TextArea";
|
||||
|
||||
export default memo(TextArea);
|
||||
export default memo(TextAreaContainer);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 { ToggleView } from "./Toggle.view";
|
||||
import type { ToggleProps } from "./Toggle.types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
|
||||
const ToggleContainer = forwardRef<HTMLButtonElement, ToggleProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
@@ -183,41 +165,32 @@ const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
|
||||
);
|
||||
|
||||
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={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={toggleClasses}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <span className="italic">{icon}</span>}
|
||||
{showText && <span>{text}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleView
|
||||
ref={ref}
|
||||
toggleId={toggleId}
|
||||
labelId={labelId}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
state={state}
|
||||
label={label}
|
||||
showIcon={showIcon}
|
||||
showText={showText}
|
||||
icon={icon}
|
||||
text={text}
|
||||
className={className}
|
||||
containerClasses={containerClasses}
|
||||
labelClasses={labelClasses}
|
||||
toggleClasses={toggleClasses}
|
||||
onClick={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Toggle.displayName = "Toggle";
|
||||
ToggleContainer.displayName = "Toggle";
|
||||
|
||||
export default memo(Toggle);
|
||||
export default memo(ToggleContainer);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Toggle.container";
|
||||
export type { ToggleProps } from "./Toggle.types";
|
||||
Reference in New Issue
Block a user