Second pass on component refactor
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import MenuBarItem from "../MenuBarItem";
|
||||||
|
import Button from "../Button";
|
||||||
|
import AvatarContainer from "../AvatarContainer";
|
||||||
|
import Avatar from "../Avatar";
|
||||||
|
import Logo from "../Logo";
|
||||||
|
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
||||||
|
import { HeaderView } from "./Header.view";
|
||||||
|
import type { HeaderProps, NavSize } from "./Header.types";
|
||||||
|
|
||||||
|
// Configuration data for testing
|
||||||
|
export const navigationItems = [
|
||||||
|
{ href: "#", text: "Use cases", extraPadding: true },
|
||||||
|
{ href: "/learn", text: "Learn" },
|
||||||
|
{ href: "#", text: "About" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const avatarImages = [
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const logoConfig = [
|
||||||
|
{ breakpoint: "block sm:hidden", size: "header" as const, showText: false },
|
||||||
|
{
|
||||||
|
breakpoint: "hidden sm:block md:hidden",
|
||||||
|
size: "header" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: "hidden md:block lg:hidden",
|
||||||
|
size: "headerMd" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: "hidden lg:block xl:hidden",
|
||||||
|
size: "headerLg" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{ breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HeaderContainer = memo<HeaderProps>(() => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Schema markup for site navigation
|
||||||
|
const schemaData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
name: "CommunityRule",
|
||||||
|
url: "https://communityrule.com",
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: "https://communityrule.com/search?q={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNavigationItems = (size: NavSize) => {
|
||||||
|
return navigationItems.map((item, index) => (
|
||||||
|
<MenuBarItem
|
||||||
|
key={index}
|
||||||
|
href={item.href}
|
||||||
|
size={item.extraPadding && size === "xsmall" ? "xsmallUseCases" : size}
|
||||||
|
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} 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}
|
||||||
|
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 (
|
||||||
|
<HeaderView
|
||||||
|
schemaData={schemaData}
|
||||||
|
navigationItems={navigationItems}
|
||||||
|
avatarImages={avatarImages}
|
||||||
|
logoConfig={logoConfig}
|
||||||
|
pathname={pathname}
|
||||||
|
renderNavigationItems={renderNavigationItems}
|
||||||
|
renderAvatarGroup={renderAvatarGroup}
|
||||||
|
renderLoginButton={renderLoginButton}
|
||||||
|
renderCreateRuleButton={renderCreateRuleButton}
|
||||||
|
renderLogo={renderLogo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HeaderContainer.displayName = "Header";
|
||||||
|
|
||||||
|
export default HeaderContainer;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
export interface HeaderProps {
|
||||||
|
// No props currently, but keeping interface for future extensibility
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderViewProps {
|
||||||
|
schemaData: {
|
||||||
|
"@context": string;
|
||||||
|
"@type": string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
potentialAction: {
|
||||||
|
"@type": string;
|
||||||
|
target: string;
|
||||||
|
"query-input": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
navigationItems: Array<{
|
||||||
|
href: string;
|
||||||
|
text: string;
|
||||||
|
extraPadding?: boolean;
|
||||||
|
}>;
|
||||||
|
avatarImages: Array<{
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
}>;
|
||||||
|
logoConfig: Array<{
|
||||||
|
breakpoint: string;
|
||||||
|
size:
|
||||||
|
| "default"
|
||||||
|
| "homeHeaderXsmall"
|
||||||
|
| "homeHeaderSm"
|
||||||
|
| "homeHeaderMd"
|
||||||
|
| "homeHeaderLg"
|
||||||
|
| "homeHeaderXl"
|
||||||
|
| "header"
|
||||||
|
| "headerMd"
|
||||||
|
| "headerLg"
|
||||||
|
| "headerXl"
|
||||||
|
| "footer"
|
||||||
|
| "footerLg";
|
||||||
|
showText: boolean;
|
||||||
|
}>;
|
||||||
|
pathname: string;
|
||||||
|
renderNavigationItems: (size: NavSize) => React.ReactNode;
|
||||||
|
renderAvatarGroup: (
|
||||||
|
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
) => React.ReactNode;
|
||||||
|
renderLoginButton: (size: NavSize) => React.ReactNode;
|
||||||
|
renderCreateRuleButton: (
|
||||||
|
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||||
|
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
) => React.ReactNode;
|
||||||
|
renderLogo: (
|
||||||
|
size:
|
||||||
|
| "default"
|
||||||
|
| "homeHeaderXsmall"
|
||||||
|
| "homeHeaderSm"
|
||||||
|
| "homeHeaderMd"
|
||||||
|
| "homeHeaderLg"
|
||||||
|
| "homeHeaderXl"
|
||||||
|
| "header"
|
||||||
|
| "headerMd"
|
||||||
|
| "headerLg"
|
||||||
|
| "headerXl"
|
||||||
|
| "footer"
|
||||||
|
| "footerLg",
|
||||||
|
showText: boolean,
|
||||||
|
) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavSize =
|
||||||
|
| "default"
|
||||||
|
| "xsmall"
|
||||||
|
| "xsmallUseCases"
|
||||||
|
| "home"
|
||||||
|
| "homeMd"
|
||||||
|
| "homeUseCases"
|
||||||
|
| "large"
|
||||||
|
| "largeUseCases"
|
||||||
|
| "homeXlarge"
|
||||||
|
| "xlarge";
|
||||||
@@ -1,151 +1,23 @@
|
|||||||
"use client";
|
import Logo from "../Logo";
|
||||||
|
import MenuBar from "../MenuBar";
|
||||||
import { memo } from "react";
|
import MenuBarItem from "../MenuBarItem";
|
||||||
import { usePathname } from "next/navigation";
|
import Button from "../Button";
|
||||||
import Logo from "./Logo";
|
import AvatarContainer from "../AvatarContainer";
|
||||||
import MenuBar from "./MenuBar";
|
import Avatar from "../Avatar";
|
||||||
import MenuBarItem from "./MenuBarItem";
|
import type { HeaderViewProps } from "./Header.types";
|
||||||
import Button from "./Button";
|
|
||||||
import AvatarContainer from "./AvatarContainer";
|
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
|
||||||
|
|
||||||
// Configuration data for testing
|
|
||||||
export const navigationItems = [
|
|
||||||
{ href: "#", text: "Use cases", extraPadding: true },
|
|
||||||
{ href: "/learn", text: "Learn" },
|
|
||||||
{ href: "#", text: "About" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const avatarImages = [
|
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const logoConfig = [
|
|
||||||
{ breakpoint: "block sm:hidden", size: "header" as const, showText: false },
|
|
||||||
{
|
|
||||||
breakpoint: "hidden sm:block md:hidden",
|
|
||||||
size: "header" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: "hidden md:block lg:hidden",
|
|
||||||
size: "headerMd" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: "hidden lg:block xl:hidden",
|
|
||||||
size: "headerLg" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{ breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const Header = memo(() => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// Schema markup for site navigation
|
|
||||||
const schemaData = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebSite",
|
|
||||||
name: "CommunityRule",
|
|
||||||
url: "https://communityrule.com",
|
|
||||||
potentialAction: {
|
|
||||||
"@type": "SearchAction",
|
|
||||||
target: "https://communityrule.com/search?q={search_term_string}",
|
|
||||||
"query-input": "required name=search_term_string",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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" ? "xsmallUseCases" : size}
|
|
||||||
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} 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}
|
|
||||||
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} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export function HeaderView({
|
||||||
|
schemaData,
|
||||||
|
navigationItems,
|
||||||
|
avatarImages,
|
||||||
|
logoConfig,
|
||||||
|
pathname,
|
||||||
|
renderNavigationItems,
|
||||||
|
renderAvatarGroup,
|
||||||
|
renderLoginButton,
|
||||||
|
renderCreateRuleButton,
|
||||||
|
renderLogo,
|
||||||
|
}: HeaderViewProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
@@ -253,8 +125,4 @@ const Header = memo(() => {
|
|||||||
</header>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
Header.displayName = "Header";
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from "./Header.container";
|
||||||
|
export type { HeaderProps } from "./Header.types";
|
||||||
|
export { navigationItems, avatarImages, logoConfig } from "./Header.container";
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo, useCallback, useId } from "react";
|
|
||||||
import RadioButton from "./RadioButton";
|
|
||||||
|
|
||||||
interface RadioOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RadioGroupProps {
|
|
||||||
name?: string;
|
|
||||||
value?: string;
|
|
||||||
onChange?: (_data: { value: string }) => void;
|
|
||||||
mode?: "standard" | "inverse";
|
|
||||||
state?: "default" | "hover" | "focus";
|
|
||||||
disabled?: boolean;
|
|
||||||
options?: RadioOption[];
|
|
||||||
className?: string;
|
|
||||||
"aria-label"?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RadioGroup = ({
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
mode = "standard",
|
|
||||||
state = "default",
|
|
||||||
disabled = false,
|
|
||||||
options = [],
|
|
||||||
className = "",
|
|
||||||
...props
|
|
||||||
}: RadioGroupProps) => {
|
|
||||||
// Generate unique ID for accessibility if not provided
|
|
||||||
const generatedId = useId();
|
|
||||||
const groupId = name || `radio-group-${generatedId}`;
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(optionValue: string) => {
|
|
||||||
if (!disabled && onChange) {
|
|
||||||
onChange({ value: optionValue });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[disabled, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`space-y-[8px] ${className}`}
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={props["aria-label"]}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{options.map((option) => {
|
|
||||||
const isSelected = value === option.value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RadioButton
|
|
||||||
key={option.value}
|
|
||||||
checked={isSelected}
|
|
||||||
mode={mode}
|
|
||||||
state={state}
|
|
||||||
disabled={disabled}
|
|
||||||
label={option.label}
|
|
||||||
name={groupId}
|
|
||||||
value={option.value}
|
|
||||||
ariaLabel={option.ariaLabel}
|
|
||||||
onChange={({ checked }) => {
|
|
||||||
if (checked) {
|
|
||||||
handleChange(option.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
RadioGroup.displayName = "RadioGroup";
|
|
||||||
|
|
||||||
export default memo(RadioGroup);
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useCallback, useId } from "react";
|
||||||
|
import { RadioGroupView } from "./RadioGroup.view";
|
||||||
|
import type { RadioGroupProps } from "./RadioGroup.types";
|
||||||
|
|
||||||
|
const RadioGroupContainer = ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
mode = "standard",
|
||||||
|
state = "default",
|
||||||
|
disabled = false,
|
||||||
|
options = [],
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: RadioGroupProps) => {
|
||||||
|
// Generate unique ID for accessibility if not provided
|
||||||
|
const generatedId = useId();
|
||||||
|
const groupId = name || `radio-group-${generatedId}`;
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(optionValue: string) => {
|
||||||
|
if (!disabled && onChange) {
|
||||||
|
onChange({ value: optionValue });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioGroupView
|
||||||
|
groupId={groupId}
|
||||||
|
value={value}
|
||||||
|
mode={mode}
|
||||||
|
state={state}
|
||||||
|
disabled={disabled}
|
||||||
|
options={options}
|
||||||
|
className={className}
|
||||||
|
ariaLabel={props["aria-label"]}
|
||||||
|
onOptionChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
RadioGroupContainer.displayName = "RadioGroup";
|
||||||
|
|
||||||
|
export default memo(RadioGroupContainer);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface RadioOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioGroupProps {
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (_data: { value: string }) => void;
|
||||||
|
mode?: "standard" | "inverse";
|
||||||
|
state?: "default" | "hover" | "focus";
|
||||||
|
disabled?: boolean;
|
||||||
|
options?: RadioOption[];
|
||||||
|
className?: string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioGroupViewProps {
|
||||||
|
groupId: string;
|
||||||
|
value?: string;
|
||||||
|
mode: "standard" | "inverse";
|
||||||
|
state: "default" | "hover" | "focus";
|
||||||
|
disabled: boolean;
|
||||||
|
options: RadioOption[];
|
||||||
|
className: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
onOptionChange: (optionValue: string) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import RadioButton from "../RadioButton";
|
||||||
|
import type { RadioGroupViewProps } from "./RadioGroup.types";
|
||||||
|
|
||||||
|
export function RadioGroupView({
|
||||||
|
groupId,
|
||||||
|
value,
|
||||||
|
mode,
|
||||||
|
state,
|
||||||
|
disabled,
|
||||||
|
options,
|
||||||
|
className,
|
||||||
|
ariaLabel,
|
||||||
|
onOptionChange,
|
||||||
|
}: RadioGroupViewProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`space-y-[8px] ${className}`}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = value === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioButton
|
||||||
|
key={option.value}
|
||||||
|
checked={isSelected}
|
||||||
|
mode={mode}
|
||||||
|
state={state}
|
||||||
|
disabled={disabled}
|
||||||
|
label={option.label}
|
||||||
|
name={groupId}
|
||||||
|
value={option.value}
|
||||||
|
ariaLabel={option.ariaLabel}
|
||||||
|
onChange={({ checked }) => {
|
||||||
|
if (checked) {
|
||||||
|
onOptionChange(option.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./RadioGroup.container";
|
||||||
|
export type { RadioGroupProps, RadioOption } from "./RadioGroup.types";
|
||||||
+12
-70
@@ -1,17 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, memo, useMemo, useCallback } from "react";
|
import { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||||
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
|
import { useIsMobile } from "../../hooks";
|
||||||
import type { BlogPost } from "../../lib/content";
|
import { RelatedArticlesView } from "./RelatedArticles.view";
|
||||||
import { useIsMobile } from "../hooks";
|
import type { RelatedArticlesProps } from "./RelatedArticles.types";
|
||||||
|
|
||||||
interface RelatedArticlesProps {
|
const RelatedArticlesContainer = memo<RelatedArticlesProps>(
|
||||||
relatedPosts: BlogPost[];
|
|
||||||
currentPostSlug: string;
|
|
||||||
slugOrder?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const RelatedArticles = memo<RelatedArticlesProps>(
|
|
||||||
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
||||||
// Memoize filtered posts to prevent unnecessary re-computations
|
// Memoize filtered posts to prevent unnecessary re-computations
|
||||||
const filteredPosts = useMemo(
|
const filteredPosts = useMemo(
|
||||||
@@ -73,8 +67,6 @@ const RelatedArticles = memo<RelatedArticlesProps>(
|
|||||||
[currentIndex, progress],
|
[currentIndex, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mobile detection is now handled by useIsMobile hook
|
|
||||||
|
|
||||||
// Auto-advance every 3 seconds (only on mobile)
|
// Auto-advance every 3 seconds (only on mobile)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filteredPosts.length <= 1 || !isMobile) return;
|
if (filteredPosts.length <= 1 || !isMobile) return;
|
||||||
@@ -103,69 +95,19 @@ const RelatedArticles = memo<RelatedArticlesProps>(
|
|||||||
return () => clearInterval(progressInterval);
|
return () => clearInterval(progressInterval);
|
||||||
}, [currentIndex, filteredPosts.length, isMobile]);
|
}, [currentIndex, filteredPosts.length, isMobile]);
|
||||||
|
|
||||||
if (filteredPosts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<RelatedArticlesView
|
||||||
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
|
filteredPosts={filteredPosts}
|
||||||
data-testid="related-articles"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
|
|
||||||
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
|
|
||||||
Related Articles
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
|
|
||||||
<div className="flex justify-center overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
|
|
||||||
!isMobile
|
|
||||||
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
style={transformStyle}
|
|
||||||
onMouseDown={!isMobile ? handleMouseDown : undefined}
|
|
||||||
>
|
|
||||||
{filteredPosts.map((relatedPost) => (
|
|
||||||
<div
|
|
||||||
key={relatedPost.slug}
|
|
||||||
className="flex flex-col items-center flex-shrink-0"
|
|
||||||
data-testid={`related-${relatedPost.slug}`}
|
|
||||||
>
|
|
||||||
<ContentThumbnailTemplate
|
|
||||||
post={relatedPost}
|
|
||||||
variant="vertical"
|
|
||||||
slugOrder={slugOrder}
|
slugOrder={slugOrder}
|
||||||
|
isMobile={isMobile}
|
||||||
|
transformStyle={transformStyle}
|
||||||
|
getProgressStyle={getProgressStyle}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bars - only show on mobile */}
|
|
||||||
{isMobile && (
|
|
||||||
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
|
|
||||||
{filteredPosts.map((relatedPost, index) => (
|
|
||||||
<div
|
|
||||||
key={relatedPost.slug}
|
|
||||||
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
|
|
||||||
style={getProgressStyle(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
RelatedArticles.displayName = "RelatedArticles";
|
RelatedArticlesContainer.displayName = "RelatedArticles";
|
||||||
|
|
||||||
export default RelatedArticles;
|
export default RelatedArticlesContainer;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { BlogPost } from "../../../lib/content";
|
||||||
|
|
||||||
|
export interface RelatedArticlesProps {
|
||||||
|
relatedPosts: BlogPost[];
|
||||||
|
currentPostSlug: string;
|
||||||
|
slugOrder?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelatedArticlesViewProps {
|
||||||
|
filteredPosts: BlogPost[];
|
||||||
|
slugOrder: string[];
|
||||||
|
isMobile: boolean;
|
||||||
|
transformStyle: React.CSSProperties;
|
||||||
|
getProgressStyle: (index: number) => React.CSSProperties;
|
||||||
|
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import ContentThumbnailTemplate from "../ContentThumbnailTemplate";
|
||||||
|
import type { RelatedArticlesViewProps } from "./RelatedArticles.types";
|
||||||
|
|
||||||
|
export function RelatedArticlesView({
|
||||||
|
filteredPosts,
|
||||||
|
slugOrder,
|
||||||
|
isMobile,
|
||||||
|
transformStyle,
|
||||||
|
getProgressStyle,
|
||||||
|
onMouseDown,
|
||||||
|
}: RelatedArticlesViewProps) {
|
||||||
|
if (filteredPosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
|
||||||
|
data-testid="related-articles"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
|
||||||
|
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
|
||||||
|
Related Articles
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
|
||||||
|
<div className="flex justify-center overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
|
||||||
|
!isMobile
|
||||||
|
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
style={transformStyle}
|
||||||
|
onMouseDown={!isMobile ? onMouseDown : undefined}
|
||||||
|
>
|
||||||
|
{filteredPosts.map((relatedPost) => (
|
||||||
|
<div
|
||||||
|
key={relatedPost.slug}
|
||||||
|
className="flex flex-col items-center flex-shrink-0"
|
||||||
|
data-testid={`related-${relatedPost.slug}`}
|
||||||
|
>
|
||||||
|
<ContentThumbnailTemplate
|
||||||
|
post={relatedPost}
|
||||||
|
variant="vertical"
|
||||||
|
slugOrder={slugOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bars - only show on mobile */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
|
||||||
|
{filteredPosts.map((relatedPost, index) => (
|
||||||
|
<div
|
||||||
|
key={relatedPost.slug}
|
||||||
|
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
|
||||||
|
style={getProgressStyle(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./RelatedArticles.container";
|
||||||
|
export type { RelatedArticlesProps } from "./RelatedArticles.types";
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
interface RuleCardProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
backgroundColor?: string;
|
|
||||||
className?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
gtag?: (
|
|
||||||
_command: string,
|
|
||||||
_eventName: string,
|
|
||||||
_params?: Record<string, unknown>,
|
|
||||||
) => void;
|
|
||||||
analytics?: {
|
|
||||||
track: (_eventName: string, _params?: Record<string, unknown>) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const RuleCard = memo<RuleCardProps>(
|
|
||||||
({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
|
||||||
className = "",
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const handleClick = () => {
|
|
||||||
// Basic analytics event tracking
|
|
||||||
if (typeof window !== "undefined" && window.gtag) {
|
|
||||||
window.gtag("event", "template_selected", {
|
|
||||||
template_name: title,
|
|
||||||
template_type: "governance_pattern",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom analytics event for other tracking systems
|
|
||||||
if (typeof window !== "undefined" && window.analytics) {
|
|
||||||
window.analytics.track("Template Selected", {
|
|
||||||
templateName: title,
|
|
||||||
templateType: "governance_pattern",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onClick) onClick();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
handleClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
aria-label={`Learn more about ${title} governance pattern`}
|
|
||||||
onClick={handleClick}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
{/* Header Container */}
|
|
||||||
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
|
|
||||||
{/* Icon Container */}
|
|
||||||
{icon && (
|
|
||||||
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Title Container */}
|
|
||||||
{title && (
|
|
||||||
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
|
|
||||||
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
RuleCard.displayName = "RuleCard";
|
|
||||||
|
|
||||||
export default RuleCard;
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { RuleCardView } from "./RuleCard.view";
|
||||||
|
import type { RuleCardProps } from "./RuleCard.types";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gtag?: (
|
||||||
|
_command: string,
|
||||||
|
_eventName: string,
|
||||||
|
_params?: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
analytics?: {
|
||||||
|
track: (_eventName: string, _params?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleCardContainer = memo<RuleCardProps>(
|
||||||
|
({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||||
|
className = "",
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
// Basic analytics event tracking
|
||||||
|
if (typeof window !== "undefined" && window.gtag) {
|
||||||
|
window.gtag("event", "template_selected", {
|
||||||
|
template_name: title,
|
||||||
|
template_type: "governance_pattern",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom analytics event for other tracking systems
|
||||||
|
if (typeof window !== "undefined" && window.analytics) {
|
||||||
|
window.analytics.track("Template Selected", {
|
||||||
|
templateName: title,
|
||||||
|
templateType: "governance_pattern",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onClick) onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RuleCardView
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
icon={icon}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
|
className={className}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
RuleCardContainer.displayName = "RuleCard";
|
||||||
|
|
||||||
|
export default RuleCardContainer;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface RuleCardProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
backgroundColor?: string;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleCardViewProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
backgroundColor: string;
|
||||||
|
className: string;
|
||||||
|
onClick: () => void;
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { RuleCardViewProps } from "./RuleCard.types";
|
||||||
|
|
||||||
|
export function RuleCardView({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
backgroundColor,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
}: RuleCardViewProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Learn more about ${title} governance pattern`}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{/* Header Container */}
|
||||||
|
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
|
||||||
|
{/* Icon Container */}
|
||||||
|
{icon && (
|
||||||
|
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Title Container */}
|
||||||
|
{title && (
|
||||||
|
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
|
||||||
|
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./RuleCard.container";
|
||||||
|
export type { RuleCardProps } from "./RuleCard.types";
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { logger } from "../../../lib/logger";
|
||||||
|
import { RuleStackView } from "./RuleStack.view";
|
||||||
|
import type { RuleStackProps } from "./RuleStack.types";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gtag?: (
|
||||||
|
_command: string,
|
||||||
|
_eventName: string,
|
||||||
|
_params?: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
analytics?: {
|
||||||
|
track: (_eventName: string, _params?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
|
||||||
|
const handleTemplateClick = (templateName: string) => {
|
||||||
|
// Basic analytics tracking
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (window.gtag) {
|
||||||
|
window.gtag("event", "template_click", {
|
||||||
|
template_name: templateName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (window.analytics) {
|
||||||
|
window.analytics.track("Template Clicked", {
|
||||||
|
templateName: templateName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug(`${templateName} template clicked`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <RuleStackView className={className} onTemplateClick={handleTemplateClick} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
RuleStackContainer.displayName = "RuleStack";
|
||||||
|
|
||||||
|
export default RuleStackContainer;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface RuleStackProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleStackViewProps {
|
||||||
|
className: string;
|
||||||
|
onTemplateClick: (templateName: string) => void;
|
||||||
|
}
|
||||||
@@ -1,47 +1,13 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import RuleCard from "./RuleCard";
|
import RuleCard from "../RuleCard";
|
||||||
import Button from "./Button";
|
import Button from "../Button";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../../lib/assetUtils";
|
||||||
import { logger } from "../../lib/logger";
|
import type { RuleStackViewProps } from "./RuleStack.types";
|
||||||
|
|
||||||
interface RuleStackProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
gtag?: (
|
|
||||||
_command: string,
|
|
||||||
_eventName: string,
|
|
||||||
_params?: Record<string, unknown>,
|
|
||||||
) => void;
|
|
||||||
analytics?: {
|
|
||||||
track: (_eventName: string, _params?: Record<string, unknown>) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|
||||||
const handleTemplateClick = (templateName: string) => {
|
|
||||||
// Basic analytics tracking
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
if (window.gtag) {
|
|
||||||
window.gtag("event", "template_click", {
|
|
||||||
template_name: templateName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (window.analytics) {
|
|
||||||
window.analytics.track("Template Clicked", {
|
|
||||||
templateName: templateName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug(`${templateName} template clicked`);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export function RuleStackView({
|
||||||
|
className,
|
||||||
|
onTemplateClick,
|
||||||
|
}: RuleStackViewProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
|
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
|
||||||
@@ -60,7 +26,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
|
||||||
onClick={() => handleTemplateClick("Consensus clusters")}
|
onClick={() => onTemplateClick("Consensus clusters")}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Consensus"
|
title="Consensus"
|
||||||
@@ -75,7 +41,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
|
||||||
onClick={() => handleTemplateClick("Consensus")}
|
onClick={() => onTemplateClick("Consensus")}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Elected Board"
|
title="Elected Board"
|
||||||
@@ -90,7 +56,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
|
||||||
onClick={() => handleTemplateClick("Elected Board")}
|
onClick={() => onTemplateClick("Elected Board")}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Petition"
|
title="Petition"
|
||||||
@@ -105,7 +71,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
|
||||||
onClick={() => handleTemplateClick("Petition")}
|
onClick={() => onTemplateClick("Petition")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,8 +83,4 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
RuleStack.displayName = "RuleStack";
|
|
||||||
|
|
||||||
export default RuleStack;
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./RuleStack.container";
|
||||||
|
export type { RuleStackProps } from "./RuleStack.types";
|
||||||
+17
-32
@@ -1,25 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback, useId, forwardRef } from "react";
|
import { memo, useCallback, useId, forwardRef } from "react";
|
||||||
|
import { ToggleGroupView } from "./ToggleGroup.view";
|
||||||
|
import type { ToggleGroupProps } from "./ToggleGroup.types";
|
||||||
|
|
||||||
interface ToggleGroupProps extends Omit<
|
const ToggleGroupContainer = memo(
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
"onChange"
|
|
||||||
> {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
position?: "left" | "middle" | "right";
|
|
||||||
state?: "default" | "hover" | "focus" | "selected";
|
|
||||||
showText?: boolean;
|
|
||||||
ariaLabel?: string;
|
|
||||||
onChange?: (
|
|
||||||
_e:
|
|
||||||
| React.MouseEvent<HTMLButtonElement>
|
|
||||||
| React.KeyboardEvent<HTMLButtonElement>,
|
|
||||||
) => void;
|
|
||||||
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
|
||||||
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToggleGroup = memo(
|
|
||||||
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, ref) => {
|
forwardRef<HTMLButtonElement, ToggleGroupProps>((props, ref) => {
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
@@ -132,25 +117,25 @@ const ToggleGroup = memo(
|
|||||||
.replace(/\s+/g, " ");
|
.replace(/\s+/g, " ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<ToggleGroupView
|
||||||
ref={ref}
|
groupId={groupId}
|
||||||
id={groupId}
|
children={children}
|
||||||
type="button"
|
className={className}
|
||||||
role="button"
|
position={position}
|
||||||
aria-label={ariaLabel || (showText ? undefined : "Toggle option")}
|
state={state}
|
||||||
|
showText={showText}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
toggleClasses={toggleClasses}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
className={toggleClasses}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
/>
|
||||||
{showText ? children : children || "☰"}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
ToggleGroup.displayName = "ToggleGroup";
|
ToggleGroupContainer.displayName = "ToggleGroup";
|
||||||
|
|
||||||
export default ToggleGroup;
|
export default ToggleGroupContainer;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export interface ToggleGroupProps
|
||||||
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
position?: "left" | "middle" | "right";
|
||||||
|
state?: "default" | "hover" | "focus" | "selected";
|
||||||
|
showText?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
onChange?: (
|
||||||
|
_e:
|
||||||
|
| React.MouseEvent<HTMLButtonElement>
|
||||||
|
| React.KeyboardEvent<HTMLButtonElement>,
|
||||||
|
) => void;
|
||||||
|
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||||
|
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToggleGroupViewProps {
|
||||||
|
groupId: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className: string;
|
||||||
|
position: "left" | "middle" | "right";
|
||||||
|
state: "default" | "hover" | "focus" | "selected";
|
||||||
|
showText: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
toggleClasses: 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,34 @@
|
|||||||
|
import type { ToggleGroupViewProps } from "./ToggleGroup.types";
|
||||||
|
|
||||||
|
export function ToggleGroupView({
|
||||||
|
groupId,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
position,
|
||||||
|
state,
|
||||||
|
showText,
|
||||||
|
ariaLabel,
|
||||||
|
toggleClasses,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
...rest
|
||||||
|
}: ToggleGroupViewProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id={groupId}
|
||||||
|
type="button"
|
||||||
|
role="button"
|
||||||
|
aria-label={ariaLabel || (showText ? undefined : "Toggle option")}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
className={toggleClasses}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{showText ? children : children || "☰"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./ToggleGroup.container";
|
||||||
|
export type { ToggleGroupProps } from "./ToggleGroup.types";
|
||||||
Reference in New Issue
Block a user