Profile page UI and functionality implemented

This commit is contained in:
adilallo
2026-04-25 17:57:58 -06:00
parent 7dd2562bae
commit 68517796a9
103 changed files with 4439 additions and 1476 deletions
+63 -18
View File
@@ -1,19 +1,56 @@
"use client";
import { memo } from "react";
import ContentCopyIcon from "./icon/content_copy.svg";
import EditIcon from "./icon/edit.svg";
import ExclamationIcon from "./icon/exclamation.svg";
import ChevronRightIcon from "./icon/chevron_right.svg";
import LogOutIcon from "./icon/log_out.svg";
import MailIcon from "./icon/mail.svg";
import WarningIcon from "./icon/warning.svg";
export type IconName = "exclamation";
export const ICON_NAME_OPTIONS = [
"chevron_right",
"content_copy",
"edit",
"exclamation",
"log_out",
"mail",
"warning",
] as const;
export type IconName = (typeof ICON_NAME_OPTIONS)[number];
type SvgComponent =
| React.ComponentType<React.SVGProps<SVGSVGElement>>
| { default: React.ComponentType<React.SVGProps<SVGSVGElement>> };
/** SVG import may be a React component or a module object { default: Component } (e.g. with Turbopack) */
const iconMap: Record<
IconName,
| React.ComponentType<React.SVGProps<SVGSVGElement>>
| { default: React.ComponentType<React.SVGProps<SVGSVGElement>> }
> = {
const iconMap: Record<IconName, SvgComponent> = {
chevron_right: ChevronRightIcon,
content_copy: ContentCopyIcon,
edit: EditIcon,
exclamation: ExclamationIcon,
log_out: LogOutIcon,
mail: MailIcon,
warning: WarningIcon,
};
function resolveSvgComponent(module: SvgComponent) {
if (
typeof module === "object" &&
module !== null &&
"default" in module
) {
return (
module as {
default: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}
).default;
}
return module as React.ComponentType<React.SVGProps<SVGSVGElement>>;
}
export interface IconProps {
name: IconName;
className?: string;
@@ -30,18 +67,26 @@ function IconComponent({
}: IconProps) {
const SvgModule = iconMap[name];
if (!SvgModule) return null;
// Turbopack/bundler may expose SVG as { default: Component } instead of the component directly
const Svg =
typeof SvgModule === "object" &&
SvgModule !== null &&
"default" in SvgModule
? (
SvgModule as {
default: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}
).default
: (SvgModule as React.ComponentType<React.SVGProps<SVGSVGElement>>);
if (typeof Svg !== "function") return null;
const resolved = resolveSvgComponent(SvgModule);
// Turbopack/webpack mismatch: `.svg` may be a URL string instead of SVGR output.
if (typeof resolved === "string") {
return (
<img
src={resolved}
width={size}
height={size}
className={className}
alt=""
aria-hidden={ariaHidden}
/>
);
}
if (resolved == null) return null;
const Svg = resolved as React.ComponentType<React.SVGProps<SVGSVGElement>>;
return (
<Svg
width={size}
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.9462 12L8.34616 7.40002L9.39999 6.34619L15.0538 12L9.39999 17.6538L8.34616 16.6L12.9462 12Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.0577 17.5C8.55256 17.5 8.125 17.325 7.77502 16.975C7.42502 16.625 7.25002 16.1974 7.25002 15.6923V4.3077C7.25002 3.80257 7.42502 3.375 7.77502 3.025C8.125 2.675 8.55256 2.5 9.0577 2.5H17.4423C17.9474 2.5 18.3749 2.675 18.7249 3.025C19.0749 3.375 19.2499 3.80257 19.2499 4.3077V15.6923C19.2499 16.1974 19.0749 16.625 18.7249 16.975C18.3749 17.325 17.9474 17.5 17.4423 17.5H9.0577ZM9.0577 16H17.4423C17.5192 16 17.5897 15.9679 17.6538 15.9038C17.7179 15.8397 17.75 15.7692 17.75 15.6923V4.3077C17.75 4.23077 17.7179 4.16024 17.6538 4.09613C17.5897 4.03203 17.5192 3.99998 17.4423 3.99998H9.0577C8.98076 3.99998 8.91025 4.03203 8.84615 4.09613C8.78203 4.16024 8.74997 4.23077 8.74997 4.3077V15.6923C8.74997 15.7692 8.78203 15.8397 8.84615 15.9038C8.91025 15.9679 8.98076 16 9.0577 16ZM5.55772 20.9999C5.0526 20.9999 4.62505 20.8249 4.27505 20.4749C3.92505 20.1249 3.75005 19.6973 3.75005 19.1922V6.3077H5.25002V19.1922C5.25002 19.2692 5.28207 19.3397 5.34617 19.4038C5.41029 19.4679 5.4808 19.5 5.55772 19.5H15.4423V20.9999H5.55772Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.15385 19H6.39038L15.6501 9.74036L14.4135 8.50381L5.15385 17.7635V19ZM18.8577 8.65576L15.4827 5.31158L16.7866 4.00776C17.0802 3.71417 17.4372 3.56738 17.8577 3.56738C18.2782 3.56738 18.6352 3.71417 18.9288 4.00776L20.1461 5.22503C20.4397 5.51862 20.5916 5.87053 20.6019 6.28078C20.6121 6.69103 20.4705 7.04295 20.1769 7.33653L18.8577 8.65576ZM17.7731 9.75573L7.02883 20.5H3.6539V17.125L14.3981 6.38078L17.7731 9.75573Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.67315 3.48079C1.67315 2.97566 1.84815 2.54809 2.19815 2.19809C2.54813 1.84809 2.97569 1.67309 3.48082 1.67309L13.8654 1.6731C14.3705 1.6731 14.7981 1.8481 15.1481 2.1981C15.4981 2.5481 15.6731 2.97566 15.6731 3.4808L15.6731 9.03845L14.1731 9.03845L14.1731 3.4808C14.1731 3.40386 14.141 3.33334 14.0769 3.26922C14.0128 3.20512 13.9423 3.17307 13.8654 3.17307L3.48082 3.17307C3.40389 3.17307 3.33337 3.20512 3.26925 3.26922C3.20515 3.33334 3.1731 3.40386 3.1731 3.48079L3.1731 20.8653C3.1731 20.9423 3.20515 21.0128 3.26925 21.0769C3.33337 21.141 3.40389 21.1731 3.48082 21.1731L13.8654 21.1731C13.9423 21.1731 14.0128 21.141 14.0769 21.0769C14.141 21.0128 14.1731 20.9423 14.1731 20.8653L14.1731 15.3077L15.6731 15.3077L15.6731 20.8653C15.6731 21.3705 15.4981 21.798 15.1481 22.148C14.7981 22.498 14.3705 22.673 13.8654 22.673L3.48082 22.673C2.97569 22.673 2.54812 22.498 2.19812 22.148C1.84812 21.798 1.67312 21.3705 1.67312 20.8653L1.67315 3.48079ZM8.4231 11.4231L19.4538 11.4231L17.6038 9.57307L18.6731 8.51924L22.3269 12.1731L18.6731 15.8269L17.6038 14.7731L19.4539 12.923L8.42312 12.923L8.4231 11.4231Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.30773 19.5C3.8026 19.5 3.37503 19.325 3.02503 18.975C2.67503 18.625 2.50003 18.1974 2.50003 17.6923V6.3077C2.50003 5.80257 2.67503 5.375 3.02503 5.025C3.37503 4.675 3.8026 4.5 4.30773 4.5H19.6923C20.1974 4.5 20.625 4.675 20.975 5.025C21.325 5.375 21.5 5.80257 21.5 6.3077V17.6923C21.5 18.1974 21.325 18.625 20.975 18.975C20.625 19.325 20.1974 19.5 19.6923 19.5H4.30773ZM12 12.5576L4.00001 7.44225V17.6923C4.00001 17.782 4.02886 17.8557 4.08656 17.9134C4.14426 17.9711 4.21798 18 4.30773 18H19.6923C19.782 18 19.8558 17.9711 19.9135 17.9134C19.9712 17.8557 20 17.782 20 17.6923V7.44225L12 12.5576ZM12 11L19.8462 5.99998H4.15386L12 11ZM4.00001 7.44225V5.99998V17.6923C4.00001 17.782 4.02886 17.8557 4.08656 17.9134C4.14426 17.9711 4.21798 18 4.30773 18H4.00001V7.44225Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 919 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.86545 20.4999L12 3L22.1346 20.4999H1.86545ZM4.45 18.9999H19.55L12 5.99993L4.45 18.9999ZM12 17.8076C12.2288 17.8076 12.4207 17.7302 12.5755 17.5754C12.7303 17.4206 12.8077 17.2288 12.8077 16.9999C12.8077 16.7711 12.7303 16.5793 12.5755 16.4245C12.4207 16.2697 12.2288 16.1923 12 16.1923C11.7711 16.1923 11.5793 16.2697 11.4245 16.4245C11.2697 16.5793 11.1923 16.7711 11.1923 16.9999C11.1923 17.2288 11.2697 17.4206 11.4245 17.5754C11.5793 17.7302 11.7711 17.8076 12 17.8076ZM11.25 15.1923H12.75V10.1923H11.25V15.1923Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

+1 -1
View File
@@ -1,3 +1,3 @@
export { default as Icon } from "./Icon";
export { default as Icon, ICON_NAME_OPTIONS } from "./Icon";
export type { IconName, IconProps } from "./Icon";
export { default as Logo } from "./logo";
+5 -5
View File
@@ -42,14 +42,14 @@ const Logo = memo<LogoProps>(
},
footer: {
containerHeight:
"h-[41px] sm:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]",
gap: "gap-[8.28px] sm:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]",
"h-[41px] md:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]",
gap: "gap-[8.28px] md:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]",
textSize:
"text-[21.97px] sm:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]",
"text-[21.97px] md:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]",
lineHeight:
"leading-[27.05px] sm:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]",
"leading-[27.05px] md:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]",
iconSize:
"w-[27.05px] h-[27.05px] sm:w-[calc(27.05px*1.37)] sm:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]",
"w-[27.05px] h-[27.05px] md:w-[calc(27.05px*1.37)] md:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]",
},
createFlow: {
containerHeight: "h-[30px] md:h-[41px]",
@@ -17,6 +17,10 @@ declare global {
}
}
/**
* Figma: "Card / Rule" — e.g. profile `22143:900771` when **Has bottom link** is on
* (`hasBottomLinks` + `bottomLinks` / optional `bottomStatusLabel`).
*/
const RuleCardContainer = memo<RuleCardProps>(
({
title,
@@ -32,10 +36,14 @@ const RuleCardContainer = memo<RuleCardProps>(
logoAlt,
communityInitials,
hideCategoryAddButton = false,
hasBottomLinks = false,
bottomStatusLabel,
bottomLinks,
}) => {
const size = sizeProp ?? "L";
const handleClick = () => {
if (hasBottomLinks) return;
// Basic analytics event tracking
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "template_selected", {
@@ -56,6 +64,7 @@ const RuleCardContainer = memo<RuleCardProps>(
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (hasBottomLinks) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
@@ -69,8 +78,8 @@ const RuleCardContainer = memo<RuleCardProps>(
icon={icon}
backgroundColor={backgroundColor}
className={className}
onClick={handleClick}
onKeyDown={handleKeyDown}
onClick={hasBottomLinks ? undefined : handleClick}
onKeyDown={hasBottomLinks ? undefined : handleKeyDown}
expanded={expanded}
size={size}
categories={categories}
@@ -78,6 +87,9 @@ const RuleCardContainer = memo<RuleCardProps>(
logoAlt={logoAlt}
communityInitials={communityInitials}
hideCategoryAddButton={hideCategoryAddButton}
hasBottomLinks={hasBottomLinks}
bottomStatusLabel={bottomStatusLabel}
bottomLinks={bottomLinks}
/>
);
},
@@ -13,6 +13,14 @@ export interface Category {
onCustomChipClose?: (categoryName: string, chipId: string) => void;
}
/** Bottom row for `Card / Rule` when Figma **Has bottom link** is on (profile, etc.). */
export interface RuleCardBottomLink {
id: string;
label: string;
href?: string;
onClick?: () => void;
}
export interface RuleCardProps {
title: string;
description?: string;
@@ -28,6 +36,15 @@ export interface RuleCardProps {
communityInitials?: string;
/** Hide the per-category "+" add chip affordance (e.g. read-only template review). */
hideCategoryAddButton?: boolean;
/**
* Figma `Card / Rule` variant: description + optional status chip + text links
* (e.g. Duplicate / Delete, or Continue / Start new rule). When set, the card
* is not a single interactive button — links handle their own actions.
*/
hasBottomLinks?: boolean;
/** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
bottomStatusLabel?: string;
bottomLinks?: RuleCardBottomLink[];
}
export interface RuleCardViewProps {
@@ -36,8 +53,8 @@ export interface RuleCardViewProps {
icon?: React.ReactNode;
backgroundColor: string;
className: string;
onClick: () => void;
onKeyDown: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
onClick?: () => void;
onKeyDown?: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
expanded: boolean;
size: "XS" | "S" | "M" | "L";
categories?: Category[];
@@ -45,4 +62,7 @@ export interface RuleCardViewProps {
logoAlt?: string;
communityInitials?: string;
hideCategoryAddButton?: boolean;
hasBottomLinks?: boolean;
bottomStatusLabel?: string;
bottomLinks?: RuleCardBottomLink[];
}
+113 -48
View File
@@ -3,7 +3,8 @@
import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext";
import MultiSelect from "../../controls/MultiSelect";
import type { RuleCardViewProps } from "./RuleCard.types";
import NavigationLink from "../../navigation/Link";
import type { RuleCardBottomLink, RuleCardViewProps } from "./RuleCard.types";
export function RuleCardView({
title,
@@ -20,9 +21,13 @@ export function RuleCardView({
logoAlt,
communityInitials,
hideCategoryAddButton = false,
hasBottomLinks = false,
bottomStatusLabel,
bottomLinks,
}: RuleCardViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
const interactiveCard = !hasBottomLinks;
// Size-based styling
const isLarge = size === "L";
@@ -70,15 +75,14 @@ export function RuleCardView({
: "" // XS and S: no fixed width
: "";
// Logo/Icon dimensions - use CSS responsive classes
// For S: 80px container with 12px padding = 56px icon area
// For XS: 72px container with 16px padding = 40px icon (72 - 16*2 = 40px)
const logoSize = 103; // Use max size, CSS will resize
// Logo/Icon dimensions (inner circle) after Figma header `pl-1 pr-2 py-2` in icon cell
// (Card / Rule — e.g. `22143:900771` / `19706:12110`); outer column width holds padding + this.
const logoSize = 103; // `next/image` prop; actual box comes from `logoContainerClass`
const logoContainerClass = `
max-[639px]:size-[72px]
min-[640px]:max-[1023px]:size-[80px]
max-[639px]:size-[56px]
min-[640px]:max-[1023px]:size-[64px]
min-[1024px]:max-[1439px]:size-[56px]
min-[1440px]:size-[103px]
min-[1440px]:size-[88px]
`;
// Title typography - use CSS responsive classes
@@ -106,7 +110,7 @@ export function RuleCardView({
logoUrl.startsWith("http://localhost") ||
logoUrl.startsWith("https://localhost");
const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`;
const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity`;
if (isLocalhost) {
return (
@@ -139,7 +143,7 @@ export function RuleCardView({
if (icon) {
return (
<div
className={`${logoContainerClass} flex items-center justify-center max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`}
className={`${logoContainerClass} flex items-center justify-center`}
>
{icon}
</div>
@@ -150,8 +154,7 @@ export function RuleCardView({
const initialsSize = `
max-[639px]:text-[16px]
min-[640px]:max-[1023px]:text-[20px]
min-[1024px]:max-[1439px]:text-[24px]
min-[1440px]:text-[36px]
min-[1024px]:text-[36px]
`;
return (
<div
@@ -178,54 +181,72 @@ export function RuleCardView({
? "rounded-[var(--measures-radius-300,12px)]"
: "rounded-[var(--radius-measures-radius-small)]";
function renderBottomLink(link: RuleCardBottomLink) {
const shared = {
variant: "paragraph" as const,
type: "primary" as const,
theme: "light" as const,
className: "shrink-0",
children: link.label,
};
if (link.href) {
return (
<NavigationLink
key={link.id}
{...shared}
href={link.href}
onClick={(e) => e.stopPropagation()}
/>
);
}
return (
<NavigationLink
key={link.id}
{...shared}
onClick={(e) => {
e.stopPropagation();
link.onClick?.();
}}
/>
);
}
return (
<div
className={`${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200 flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${className || ""}`}
tabIndex={0}
role="button"
className={`${backgroundColor} ${cardPadding} ${cardGap} ${borderRadiusClass} shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] ${interactiveCard ? "hover:shadow-[0px_0px_64px_0px_rgba(0,0,0,0.15)] transition-shadow duration-200" : ""} flex flex-col items-start justify-center relative ${cardWidth || "w-full"} ${className || ""}`}
tabIndex={interactiveCard ? 0 : undefined}
role={interactiveCard ? "button" : "article"}
aria-label={ariaLabel}
aria-expanded={expanded}
onClick={onClick}
onKeyDown={onKeyDown}
aria-expanded={interactiveCard ? expanded : undefined}
onClick={interactiveCard ? onClick : undefined}
onKeyDown={interactiveCard ? onKeyDown : undefined}
>
{/* Outermost container with bottom border - taller to match Figma */}
{/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
<div
className={`
border-b border-solid border-[var(--color-content-invert-primary)] flex items-center relative shrink-0 w-full
max-[639px]:h-[72px]
min-[640px]:max-[1023px]:h-[80px]
min-[1024px]:max-[1439px]:h-[88px]
min-[1440px]:h-[136px]
`}
className="
border-b border-solid border-[var(--color-content-invert-primary)] flex
w-full shrink-0 items-center gap-px
"
>
{/* Logo/Icon - fixed width/height, vertically centered, does not touch bottom */}
{renderLogo() && (
<div
className={`
flex items-center justify-center shrink-0
max-[639px]:w-[72px] max-[639px]:h-[72px] max-[639px]:border-r max-[639px]:border-solid max-[639px]:border-[var(--color-content-invert-primary)]
min-[640px]:max-[1023px]:w-[80px] min-[640px]:max-[1023px]:h-[80px] min-[640px]:max-[1023px]:border-r min-[640px]:max-[1023px]:border-solid min-[640px]:max-[1023px]:border-[var(--color-content-invert-primary)]
min-[1024px]:max-[1439px]:w-[56px] min-[1024px]:max-[1439px]:h-[56px]
min-[1440px]:w-[103px] min-[1440px]:h-[103px]
`}
className="
flex shrink-0 items-center justify-center
pl-[4px] pr-[8px] py-[8px]
max-[639px]:w-[72px]
min-[640px]:max-[1023px]:w-[80px]
min-[1024px]:w-[119px]
"
>
{renderLogo()}
</div>
)}
{/* Spacing between icon and title */}
<div
className="
max-[1023px]:hidden
min-[1024px]:w-[16px] min-[1024px]:shrink-0
"
/>
{/* Container with no padding and left border - extends full height to touch bottom */}
{title && (
<div
className={`
flex-1 min-w-0 h-full flex
max-[1023px]:border-0
min-[1024px]:border-l min-[1024px]:border-solid min-[1024px]:border-[var(--color-content-invert-primary)]
flex min-w-0 flex-1 flex-col justify-center
min-h-[72px] min-[640px]:min-h-[80px] min-[1024px]:min-h-[88px] min-[1440px]:min-h-[136px]
border-l border-solid border-[var(--color-content-invert-primary)]
`}
>
{/* Inner container for header text with padding */}
@@ -234,8 +255,7 @@ export function RuleCardView({
flex items-center justify-center w-full
max-[639px]:pl-[8px] max-[639px]:py-[8px]
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
min-[1024px]:max-[1439px]:px-[16px] min-[1024px]:max-[1439px]:py-[12px]
min-[1440px]:px-[16px] min-[1440px]:py-[24px]
min-[1024px]:px-[16px] min-[1024px]:py-[24px]
`}
>
<h3
@@ -248,7 +268,51 @@ export function RuleCardView({
)}
</div>
{expanded ? (
{hasBottomLinks ? (
<div
className={`flex w-full shrink-0 flex-col ${isLarge ? "gap-6" : "gap-4"}`}
>
{description ? (
<p
className={`w-full ${descriptionClass} text-[var(--color-content-invert-primary)]`}
>
{description}
</p>
) : null}
{bottomLinks && bottomLinks.length > 0 ? (
<div
className={[
"flex w-full min-w-0 flex-nowrap items-center",
bottomStatusLabel ? "justify-between gap-2" : "justify-end",
].join(" ")}
data-figma-node="21867:47400"
>
{bottomStatusLabel ? (
<span className="shrink-0 rounded-[2px] bg-[var(--color-surface-default-tertiary)] px-1 py-0.5 font-inter text-[10px] font-medium uppercase leading-3 text-[var(--color-surface-invert-brand-teal)]">
{bottomStatusLabel}
</span>
) : null}
{/**
* Figma `22143:900539` / `21867:46099`: one row — status (optional) + all links in
* a single `flex-nowrap` group (`space/800` = 32px between links on large).
* If the row is too narrow, scroll horizontally; links never wrap.
*/}
<div
className={[
"flex min-w-0 flex-nowrap items-center justify-end overflow-x-auto [scrollbar-width:thin]",
bottomStatusLabel ? "min-w-0 flex-1" : "w-auto",
isLarge
? "gap-3 sm:gap-6 lg:gap-8"
: "gap-2 min-[400px]:gap-3 sm:gap-4 lg:gap-8",
].join(" ")}
data-figma-node="21867:46099"
>
{bottomLinks.map((link) => renderBottomLink(link))}
</div>
</div>
) : null}
</div>
) : expanded ? (
<>
{/* Categories Section - Using MultiSelect */}
{categories && categories.length > 0 && (
@@ -314,3 +378,4 @@ export function RuleCardView({
</div>
);
}
+4 -1
View File
@@ -1,2 +1,5 @@
export { default } from "./RuleCard.container";
export type { RuleCardProps } from "./RuleCard.types";
export type {
RuleCardBottomLink,
RuleCardProps,
} from "./RuleCard.types";
@@ -36,7 +36,7 @@ export function TemplateChipDetailModal({
<Create
isOpen={isOpen}
onClose={onClose}
backdropVariant="loginYellow"
backdropVariant="blurredYellow"
headerContent={
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
@@ -29,6 +29,7 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
showHelpIcon = true,
textHint = false,
formHeader = true,
maxLength,
...props
},
ref,
@@ -242,6 +243,7 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
focusRingClasses={stateStyles.focusRing}
textHint={textHint}
formHeader={formHeader}
maxLength={maxLength}
{...props}
/>
);
@@ -64,4 +64,5 @@ export interface TextInputViewProps {
focusRingClasses?: string;
textHint?: boolean | string;
formHeader?: boolean;
maxLength?: number;
}
@@ -28,6 +28,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
focusRingClasses = "",
textHint = false,
formHeader = true,
maxLength,
},
ref,
) => {
@@ -70,6 +71,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
onBlur={handleBlur}
onMouseDown={handleMouseDown}
disabled={disabled}
maxLength={maxLength}
className={inputClasses}
style={{ borderRadius }}
/>
@@ -0,0 +1,17 @@
"use client";
import { memo } from "react";
import { ListView } from "./List.view";
import type { ListProps } from "./List.types";
/**
* Figma: "List Edit" list frame — S (21863:45631), M (21863:45493), L (21844:4405).
* Composes {@link ListEntry} rows with a shared list-level top rule when enabled.
*/
const ListContainer = memo<ListProps>((props) => {
return <ListView {...props} />;
});
ListContainer.displayName = "List";
export default ListContainer;
+29
View File
@@ -0,0 +1,29 @@
import type { IconName } from "../../asset/Icon";
import type {
ListEntryVariant,
ListSize,
} from "../ListEntry/ListEntry.types";
export type ListItem = {
id: string;
title: string;
description: string;
href?: string;
onClick?: () => void;
/** Per-row icon; falls back to list-level {@link ListProps.leadingIcon}. */
leadingIcon?: IconName;
variant?: ListEntryVariant;
showDescription?: boolean;
};
export type ListProps = {
items: ListItem[];
size?: ListSize;
topDivider?: boolean;
leadingIcon?: IconName;
className?: string;
};
export type { ListEntryVariant, ListSize };
export type ListViewProps = ListProps;
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { memo } from "react";
import Divider from "../../utility/Divider";
import ListEntry from "../ListEntry";
import { FIGMA_LIST_ROOT } from "../listSizeLayout";
import type { ListViewProps } from "./List.types";
export const ListView = memo(function ListView({
items,
size = "m",
topDivider = true,
leadingIcon = "edit",
className = "",
}: ListViewProps) {
return (
<div
className={`flex w-full max-w-[1590px] flex-col items-start ${className}`}
data-figma-node={FIGMA_LIST_ROOT[size]}
>
{topDivider ? <Divider type="content" orientation="horizontal" /> : null}
<ul className="m-0 flex w-full list-none flex-col items-start p-0">
{items.map((item) => (
<li
key={item.id}
className="flex w-full flex-col items-stretch [list-style:none]"
>
<ListEntry
title={item.title}
description={item.description}
showDescription={item.showDescription}
href={item.href}
onClick={item.onClick}
size={size}
leadingIcon={item.leadingIcon ?? leadingIcon}
variant={item.variant}
topDivider={false}
bottomDivider
/>
</li>
))}
</ul>
</div>
);
});
ListView.displayName = "ListView";
+3
View File
@@ -0,0 +1,3 @@
export { default } from "./List.container";
export type { ListProps, ListItem, ListSize, ListViewProps } from "./List.types";
export { LIST_SIZE_OPTIONS } from "../ListEntry/ListEntry.types";
@@ -0,0 +1,17 @@
"use client";
import { memo } from "react";
import { ListEntryView } from "./ListEntry.view";
import type { ListEntryProps } from "./ListEntry.types";
/**
* Figma: "Base / Interactive" (21844:4118). Single list row: optional top rule,
* leading icon, title, optional description, chevron, optional bottom rule.
*/
const ListEntryContainer = memo<ListEntryProps>((props) => {
return <ListEntryView {...props} />;
});
ListEntryContainer.displayName = "ListEntry";
export default ListEntryContainer;
@@ -0,0 +1,27 @@
import type { IconName } from "../../asset/Icon";
export const LIST_SIZE_OPTIONS = ["s", "m", "l"] as const;
export type ListSize = (typeof LIST_SIZE_OPTIONS)[number];
export const LIST_ENTRY_VARIANT_OPTIONS = ["default", "danger", "muted"] as const;
export type ListEntryVariant = (typeof LIST_ENTRY_VARIANT_OPTIONS)[number];
export type ListEntryProps = {
title: string;
description?: string;
/** @default true */
showDescription?: boolean;
href?: string;
onClick?: () => void;
size?: ListSize;
leadingIcon?: IconName;
/** Row tone (e.g. profile destructive / disabled rows). @default "default" */
variant?: ListEntryVariant;
/** Renders a line above the row (Base / Interactive). @default false */
topDivider?: boolean;
/** Renders a line under the row. @default true */
bottomDivider?: boolean;
className?: string;
};
export type ListEntryViewProps = ListEntryProps;
@@ -0,0 +1,173 @@
"use client";
import { memo } from "react";
import Link from "next/link";
import Icon, { type IconName } from "../../asset/Icon";
import Divider from "../../utility/Divider";
import { FIGMA_LIST_ENTRY_OUTER, listEntrySizeLayout } from "../listSizeLayout";
import type {
ListEntryViewProps,
ListEntryVariant,
ListSize,
} from "./ListEntry.types";
type RowCoreProps = {
title: string;
description?: string;
showDescription: boolean;
href?: string;
onClick?: () => void;
leadingIcon: IconName;
size: ListSize;
variant: ListEntryVariant;
};
const ListEntryRow = memo(function ListEntryRow({
title,
description,
showDescription,
href,
onClick,
leadingIcon,
size,
variant,
}: RowCoreProps) {
const layout = listEntrySizeLayout[size];
const leadingBoxClass =
size === "s"
? "flex h-6 w-6 shrink-0 items-center justify-center"
: size === "m"
? "flex size-8 shrink-0 items-center justify-center"
: "flex size-10 shrink-0 items-center justify-center";
const chevronSize = size === "s" ? 16 : size === "l" ? 32 : 24;
const shellExtra =
variant === "muted" ? "opacity-60 hover:!bg-transparent" : "";
const titleClass =
variant === "danger"
? `${layout.title} !text-[var(--color-content-default-negative-primary)]`
: layout.title;
const leadingToneClass =
variant === "danger"
? "text-[var(--color-content-default-negative-primary)]"
: "text-[var(--color-content-default-primary)]";
const chevronToneClass =
variant === "danger"
? "text-[var(--color-content-default-negative-primary)]"
: "text-[var(--color-content-default-primary)]";
const leadingSlot = (
<div className={`${leadingBoxClass} ${leadingToneClass}`}>
<Icon name={leadingIcon} size={24} />
</div>
);
const chevronSlot = (
<div
className={
size === "s"
? `flex size-4 shrink-0 items-center justify-center ${chevronToneClass}`
: size === "l"
? `flex size-8 shrink-0 items-center justify-center ${chevronToneClass}`
: `flex size-6 shrink-0 items-center justify-center ${chevronToneClass}`
}
>
<Icon name="chevron_right" size={chevronSize} />
</div>
);
const textBlock = (
<>
<div className="flex w-full min-w-0 items-center justify-between">
<p className={titleClass}>{title}</p>
</div>
{showDescription && description != null && description !== "" ? (
<p className={layout.description}>{description}</p>
) : null}
</>
);
const inner = (
<>
{leadingSlot}
<div className={layout.textCol}>{textBlock}</div>
{chevronSlot}
</>
);
const shellClass = `${layout.shell} ${shellExtra}`.trim();
if (href) {
return (
<Link
href={href}
className={shellClass}
data-figma-node={layout.rowFigma}
>
{inner}
</Link>
);
}
if (onClick) {
return (
<button
type="button"
onClick={onClick}
className={shellClass}
data-figma-node={layout.rowFigma}
>
{inner}
</button>
);
}
return (
<div className={shellClass} data-figma-node={layout.rowFigma}>
{inner}
</div>
);
});
ListEntryRow.displayName = "ListEntryRow";
export const ListEntryView = memo(function ListEntryView({
title,
description = "",
showDescription = true,
href,
onClick,
size = "m",
leadingIcon = "edit",
variant = "default",
topDivider = false,
bottomDivider = true,
className = "",
}: ListEntryViewProps) {
return (
<div
className={`flex w-full flex-col items-start ${className}`}
data-figma-node={FIGMA_LIST_ENTRY_OUTER[size]}
>
{topDivider ? <Divider type="content" orientation="horizontal" /> : null}
<ListEntryRow
title={title}
description={description}
showDescription={showDescription}
href={href}
onClick={onClick}
leadingIcon={leadingIcon}
size={size}
variant={variant}
/>
{bottomDivider ? <Divider type="content" orientation="horizontal" /> : null}
</div>
);
});
ListEntryView.displayName = "ListEntryView";
@@ -0,0 +1,3 @@
export { default } from "./ListEntry.container";
export type { ListEntryProps, ListSize } from "./ListEntry.types";
export { LIST_SIZE_OPTIONS } from "./ListEntry.types";
+69
View File
@@ -0,0 +1,69 @@
import type { ListSize } from "./ListEntry/ListEntry.types";
export const rowShellBase =
"flex w-full cursor-pointer items-center text-left text-[var(--color-content-default-primary)] outline-none " +
"transition-colors " +
"hover:bg-[var(--color-surface-default-tertiary)] " +
"focus-visible:ring-2 focus-visible:ring-[var(--color-content-default-primary)] " +
"focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]";
/**
* Figma: "ListEntry" / Base Interactive (21844:4118) — S/M/L row + outer shell node ids.
* Full list frame roots: 21863:45631 (S), 21863:45493 (M), 21844:4405 (L).
*/
export const FIGMA_LIST_ENTRY_OUTER: Record<ListSize, string> = {
s: "21863:45436",
m: "21863:45422",
l: "21844:4119",
};
export const FIGMA_LIST_ROOT: Record<ListSize, string> = {
s: "21863:45631",
m: "21863:45493",
l: "21844:4405",
};
export const FIGMA_LIST_ENTRY_ROW: Record<ListSize, string> = {
s: "21863:45438",
m: "21863:45424",
l: "21844:4120",
};
type RowLayout = {
shell: string;
textCol: string;
title: string;
description: string;
rowFigma: string;
};
export const listEntrySizeLayout: Record<ListSize, RowLayout> = {
s: {
shell: `${rowShellBase} min-h-0 gap-1.5 py-[var(--spacing-scale-012)]`,
textCol: "flex min-w-0 flex-1 flex-col items-start justify-center",
title:
"min-w-0 flex-1 font-inter text-sm font-medium leading-[18px] text-[var(--color-content-default-primary)]",
description:
"w-full font-inter text-xs font-normal leading-4 text-[var(--color-content-default-secondary)]",
rowFigma: FIGMA_LIST_ENTRY_ROW.s,
},
m: {
shell: `${rowShellBase} min-h-16 gap-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)]`,
textCol: "flex min-w-0 flex-1 flex-col items-start justify-center",
title:
"min-w-0 flex-1 font-inter text-lg font-medium leading-6 text-[var(--color-content-default-primary)]",
description:
"w-full font-inter text-base font-normal leading-6 text-[var(--color-content-default-secondary)]",
rowFigma: FIGMA_LIST_ENTRY_ROW.m,
},
l: {
shell: `${rowShellBase} min-h-16 gap-[var(--spacing-scale-012)] py-[var(--spacing-scale-016)]`,
textCol:
"flex min-w-0 flex-1 flex-col items-start justify-center gap-[var(--spacing-scale-004)]",
title:
"min-w-0 flex-1 font-inter text-2xl font-normal leading-7 text-[var(--color-content-default-primary)]",
description:
"w-full font-inter text-lg font-normal leading-[1.3] text-[var(--color-content-default-secondary)]",
rowFigma: FIGMA_LIST_ENTRY_ROW.l,
},
};
@@ -1,8 +1,9 @@
"use client";
import { memo, useEffect, useRef } from "react";
import { memo, useRef } from "react";
import { CreateView } from "./Create.view";
import type { CreateProps } from "./Create.types";
import { useCreateModalA11y } from "./useCreateModalA11y";
const CreateContainer = memo<CreateProps>(
({
@@ -29,85 +30,8 @@ const CreateContainer = memo<CreateProps>(
}) => {
const createRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
// Handle ESC key to close
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
// Focus trap and body scroll lock
useEffect(() => {
if (!isOpen) return;
// Store previous active element
previousActiveElementRef.current = document.activeElement as HTMLElement;
// Lock body scroll
document.body.style.overflow = "hidden";
// Focus the first focusable element in the create dialog
if (createRef.current) {
const focusableElements = createRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;
if (firstElement) {
firstElement.focus();
} else {
// Fallback: make create dialog focusable and focus it
createRef.current.setAttribute("tabindex", "-1");
createRef.current.focus();
}
}
// Focus trap
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !createRef.current) return;
const focusableElements = createRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener("keydown", handleTab);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab);
// Restore focus to previous element
previousActiveElementRef.current?.focus();
};
}, [isOpen]);
useCreateModalA11y(isOpen, onClose, createRef);
return (
<CreateView
+8 -5
View File
@@ -1,3 +1,6 @@
import type { RefObject } from "react";
import type { CreateModalBackdropVariant } from "./CreateModalFrame.view";
export interface CreateProps {
isOpen: boolean;
onClose: () => void;
@@ -28,10 +31,10 @@ export interface CreateProps {
upload?: boolean;
proportion?: boolean;
/**
* Backdrop behind the dialog. `loginYellow` matches the Login modals blurred brand overlay.
* Backdrop behind the dialog. `blurredYellow` matches the login-style blurred brand overlay.
* @default "default"
*/
backdropVariant?: "default" | "loginYellow";
backdropVariant?: CreateModalBackdropVariant;
}
export interface CreateViewProps {
@@ -54,7 +57,7 @@ export interface CreateViewProps {
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
createRef: React.RefObject<HTMLDivElement>;
overlayRef: React.RefObject<HTMLDivElement>;
backdropVariant: "default" | "loginYellow";
createRef: RefObject<HTMLDivElement | null>;
overlayRef: RefObject<HTMLDivElement | null>;
backdropVariant: CreateModalBackdropVariant;
}
+40 -71
View File
@@ -1,20 +1,11 @@
"use client";
import { createPortal } from "react-dom";
import ContentLockup from "../../type/ContentLockup";
import ModalFooter from "../../utility/ModalFooter";
import ModalHeader from "../../utility/ModalHeader";
import { CreateModalFrameView } from "./CreateModalFrame.view";
import type { CreateViewProps } from "./Create.types";
const backdropOverlayClasses: Record<
CreateViewProps["backdropVariant"],
string
> = {
default: "fixed inset-0 bg-black/50 z-[9998]",
loginYellow:
"fixed inset-0 z-[9998] bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
};
export function CreateView({
isOpen,
onClose,
@@ -39,70 +30,48 @@ export function CreateView({
overlayRef,
backdropVariant,
}: CreateViewProps) {
if (!isOpen) return null;
return (
<CreateModalFrameView
isOpen={isOpen}
onOverlayClick={onClose}
backdropVariant={backdropVariant}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
overlayRef={overlayRef}
dialogRef={createRef}
>
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
const createContent = (
<>
{/* Overlay */}
<div
ref={overlayRef}
className={backdropOverlayClasses[backdropVariant]}
onClick={onClose}
aria-hidden="true"
/>
{/* Create Dialog: max-h ensures modal fits viewport; content scrolls inside */}
<div
ref={createRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[90vh] flex min-h-0 flex-col overflow-hidden z-[9999] ${className}`}
>
{/* Header with close buttons */}
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
{/* Header: custom headerContent (when provided) or default title/description */}
{headerContent !== undefined ? (
<div className="shrink-0">{headerContent}</div>
) : title || description ? (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
/>
</div>
) : null}
{/* Content Area (scrollable when content overflows) */}
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
{children}
{headerContent !== undefined ? (
<div className="shrink-0">{headerContent}</div>
) : title || description ? (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
/>
</div>
) : null}
{/* Footer (always visible at bottom of modal) */}
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
onNext={onNext}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
footerContent={footerContent}
/>
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
{children}
</div>
</>
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
onNext={onNext}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
footerContent={footerContent}
/>
</CreateModalFrameView>
);
// Portal to body
if (typeof window !== "undefined") {
return createPortal(createContent, document.body);
}
return null;
}
@@ -0,0 +1,69 @@
"use client";
import type { ReactNode, RefObject } from "react";
import { createPortal } from "react-dom";
/** Matches {@link CreateView} overlay options — shared with {@link DialogView}. */
export type CreateModalBackdropVariant = "default" | "blurredYellow";
const backdropOverlayClasses: Record<CreateModalBackdropVariant, string> = {
default: "fixed inset-0 bg-black/50 z-[9998]",
blurredYellow:
"fixed inset-0 z-[9998] bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
};
export type CreateModalFrameViewProps = {
isOpen: boolean;
onOverlayClick: () => void;
backdropVariant: CreateModalBackdropVariant;
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
overlayRef: RefObject<HTMLDivElement | null>;
dialogRef: RefObject<HTMLDivElement | null>;
children: ReactNode;
};
/**
* Portal + dimmed overlay + centered dialog shell used by Create and Dialog.
*/
export function CreateModalFrameView({
isOpen,
onOverlayClick,
backdropVariant,
className,
ariaLabel,
ariaLabelledBy,
overlayRef,
dialogRef,
children,
}: CreateModalFrameViewProps) {
if (!isOpen) return null;
const content = (
<>
<div
ref={overlayRef}
className={backdropOverlayClasses[backdropVariant]}
onClick={onOverlayClick}
aria-hidden="true"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-[var(--color-surface-default-primary)] rounded-[var(--radius-500,20px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] w-[560px] max-h-[90vh] flex min-h-0 flex-col overflow-hidden z-[9999] ${className}`}
>
{children}
</div>
</>
);
if (typeof window !== "undefined") {
return createPortal(content, document.body);
}
return null;
}
@@ -0,0 +1,82 @@
"use client";
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
/**
* Escape-to-close, body scroll lock, focus move-in and tab trap for Create-shell modals.
*/
export function useCreateModalA11y(
isOpen: boolean,
onClose: () => void,
dialogRef: RefObject<HTMLDivElement | null>,
): void {
const previousActiveElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen) return;
previousActiveElementRef.current = document.activeElement as HTMLElement;
document.body.style.overflow = "hidden";
if (dialogRef.current) {
const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;
if (firstElement) {
firstElement.focus();
} else {
dialogRef.current.setAttribute("tabindex", "-1");
dialogRef.current.focus();
}
}
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener("keydown", handleTab);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab);
previousActiveElementRef.current?.focus();
};
}, [isOpen]);
}
@@ -0,0 +1,50 @@
"use client";
import { memo, useId, useRef } from "react";
import { useCreateModalA11y } from "../Create/useCreateModalA11y";
import { DialogView } from "./Dialog.view";
import type { DialogProps } from "./Dialog.types";
const DialogContainer = memo<DialogProps>(
({
isOpen,
onClose,
title,
description,
footer,
children,
className = "",
ariaLabel,
ariaLabelledBy: ariaLabelledByProp,
backdropVariant = "default",
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const autoTitleId = useId();
const titleId = ariaLabelledByProp ?? autoTitleId;
useCreateModalA11y(isOpen, onClose, dialogRef);
return (
<DialogView
isOpen={isOpen}
onClose={onClose}
title={title}
description={description}
footer={footer}
children={children}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={titleId}
titleId={titleId}
backdropVariant={backdropVariant}
overlayRef={overlayRef}
dialogRef={dialogRef}
/>
);
},
);
DialogContainer.displayName = "Dialog";
export default DialogContainer;
@@ -0,0 +1,37 @@
import type { ReactNode, RefObject } from "react";
import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view";
export interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
/** Primary actions row (e.g. Cancel + Confirm) — use design-system `Button`s. */
footer: ReactNode;
/** Optional body below the title block (scrolls when tall). */
children?: ReactNode;
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
/**
* Same backdrop options as the Create modal shell.
* @default "default"
*/
backdropVariant?: CreateModalBackdropVariant;
}
export interface DialogViewProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
footer: ReactNode;
children?: ReactNode;
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
titleId: string;
backdropVariant: CreateModalBackdropVariant;
overlayRef: RefObject<HTMLDivElement | null>;
dialogRef: RefObject<HTMLDivElement | null>;
}
@@ -0,0 +1,68 @@
"use client";
import { memo } from "react";
import ContentLockup from "../../type/ContentLockup";
import ModalFooter from "../../utility/ModalFooter";
import ModalHeader from "../../utility/ModalHeader";
import { CreateModalFrameView } from "../Create/CreateModalFrame.view";
import type { DialogViewProps } from "./Dialog.types";
export const DialogView = memo(function DialogView({
isOpen,
onClose,
title,
description,
footer,
children,
className,
ariaLabel,
ariaLabelledBy,
titleId,
backdropVariant,
overlayRef,
dialogRef,
}: DialogViewProps) {
return (
<CreateModalFrameView
isOpen={isOpen}
onOverlayClick={onClose}
backdropVariant={backdropVariant}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
overlayRef={overlayRef}
dialogRef={dialogRef}
>
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
titleId={titleId}
/>
</div>
{children ? (
<div className="scrollbar-design flex min-h-0 flex-1 flex-col gap-[var(--spacing-scale-024)] overflow-x-clip overflow-y-auto px-[24px] pb-6 pt-0">
{children}
</div>
) : null}
<ModalFooter
showBackButton={false}
showNextButton={false}
stepper={false}
footerContent={
<div className="absolute right-[16px] top-[12px] flex max-w-[calc(100%-32px)] flex-wrap items-center justify-end gap-3">
{footer}
</div>
}
/>
</CreateModalFrameView>
);
});
DialogView.displayName = "DialogView";
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Dialog.container";
export type { DialogProps } from "./Dialog.types";
+156 -103
View File
@@ -7,9 +7,28 @@ import Logo from "../asset/logo";
import Separator from "../utility/Separator";
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
/**
* Figma: "Navigation / Footer" (18411-62917).
* Tiers: smallest viewports (below `md`), `md` through `lg`, `lg` and up.
* Matches `--breakpoint-md: 640px`, `--breakpoint-lg: 1024px` in `app/tailwind.css`.
*/
const Footer = memo(() => {
const t = useTranslation("footer");
const linkFocusClass =
"hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity";
const bodyTextClass =
"text-[var(--color-content-default-primary)] font-inter text-base font-medium leading-5 tracking-[0%] lg:text-2xl lg:font-normal lg:leading-7";
/** Figma 18411:62925 (1024+): org name is one line, `w-full whitespace-nowrap`. */
const orgNameClass = `${bodyTextClass} lg:whitespace-nowrap`;
const primaryLinkClass = `text-[var(--color-content-default-primary)] font-inter text-base font-medium leading-5 tracking-[0%] ${linkFocusClass} p-2 -m-2 cursor-pointer lg:text-2xl lg:font-normal lg:leading-7`;
/** Figma 18411:62944: 40px gaps, w-[396px] link block; `p-2` on links overruns 396px—tighten x at `md+` row. */
const legalLinkClass = `text-[var(--color-content-default-secondary)] font-inter text-sm font-normal leading-5 tracking-[0%] ${linkFocusClass} p-2 -m-2 cursor-pointer underline decoration-solid [text-decoration-skip-ink:none] md:px-0 md:py-1 md:mx-0 md:text-xs md:leading-4 md:whitespace-nowrap md:no-underline md:text-[var(--color-content-default-primary)] lg:text-sm lg:leading-5 lg:text-[var(--color-content-default-primary)]`;
// Schema markup for organization information
const schemaData = {
"@context": "https://schema.org",
@@ -28,126 +47,160 @@ const Footer = memo(() => {
/>
<footer className="bg-[var(--color-surface-default-primary)] w-full">
<div
className="flex flex-col items-start mx-auto
className="mx-auto flex max-w-[1920px] flex-col
gap-[var(--spacing-measures-spacing-040)]
px-[var(--spacing-measures-spacing-016)]
py-[var(--spacing-measures-spacing-040)]
gap-[var(--spacing-measures-spacing-040)]
sm:px-[var(--spacing-measures-spacing-032)]
sm:py-[var(--spacing-measures-spacing-024)]
sm:gap-[var(--spacing-measures-spacing-024)]
lg:px-[var(--spacing-measures-spacing-120,120px)]
lg:py-[var(--spacing-measures-spacing-096,96px)]
lg:gap-[var(--spacing-measures-spacing-060,60px)]"
md:gap-[var(--spacing-measures-spacing-024)]
md:px-[var(--spacing-measures-spacing-032)]
md:py-[var(--spacing-measures-spacing-024)]
lg:gap-[var(--spacing-measures-spacing-060,60px)]
lg:px-[var(--spacing-scale-064)]
lg:py-[var(--spacing-scale-096)]"
>
{/* Logo */}
<Logo size="footer" wordmark />
<div
className="flex w-full flex-col
gap-[var(--spacing-scale-032)]
md:gap-[var(--spacing-scale-048)]
lg:gap-[var(--spacing-measures-spacing-060,60px)]"
>
<Logo size="footer" wordmark />
{/* Content section */}
<div className="flex flex-col items-start w-full gap-[var(--spacing-measures-spacing-048,48px)] sm:flex-row sm:justify-between sm:gap-0">
{/* Branding Section */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-064,64px)] order-2 sm:order-1">
{/* Contact info */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
<div className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal">
{t("organization.name")}
<div
className="flex w-full flex-col
gap-[var(--spacing-scale-048)]
md:flex-row md:items-start md:justify-between md:gap-0"
>
<div
className="order-2 flex flex-col
gap-[var(--spacing-scale-048)]
md:order-1 md:max-w-[min(100%,334px)]
lg:max-w-[min(100%,334px)]
lg:gap-[var(--spacing-scale-064)]"
>
<div
className="flex flex-col
gap-[var(--spacing-measures-spacing-016,16px)]"
>
<div className={orgNameClass}>{t("organization.name")}</div>
<a
href={`mailto:${t("organization.email")}`}
className={`${bodyTextClass} ${linkFocusClass} p-2 -m-2 cursor-pointer`}
>
{t("organization.email")}
</a>
</div>
<a
href={`mailto:${t("organization.email")}`}
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
<div
className="flex flex-col
gap-[var(--spacing-measures-spacing-016,16px)]"
>
{t("organization.email")}
</a>
<a
href={t("social.bluesky.url")}
className={`group flex items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
aria-label={t("social.bluesky.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social logo */}
<img
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
alt="Bluesky"
width={24}
height={22}
className="h-[21px] w-[24px] flex-shrink-0 transition-transform group-hover:scale-110"
/>
<div className={bodyTextClass}>{t("social.bluesky.handle")}</div>
</a>
<a
href={t("social.gitlab.url")}
className={`group flex items-center gap-[var(--spacing-measures-spacing-06,6px)] ${linkFocusClass} p-2 -m-2 cursor-pointer`}
aria-label={t("social.gitlab.ariaLabel")}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
<img
src={getAssetPath(ASSETS.GITLAB_ICON)}
alt="GitLab"
width={22}
height={22}
className="h-5 w-[22px] flex-shrink-0 grayscale transition-transform group-hover:scale-110"
/>
<div className={bodyTextClass}>{t("social.gitlab.handle")}</div>
</a>
</div>
</div>
{/* Social media links */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
<a
href={t("social.bluesky.url")}
className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer group"
aria-label={t("social.bluesky.ariaLabel")}
<nav
aria-label="Footer"
className="order-1 flex w-full max-w-full flex-col
items-start
gap-[var(--spacing-scale-032)]
md:order-2 md:w-auto md:items-end md:text-right
md:gap-[var(--spacing-scale-032)]"
>
<Link
href="#"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social logo */}
<img
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
alt="Bluesky"
width={24}
height={22}
className="flex-shrink-0 group-hover:scale-110 transition-transform"
/>
<div className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal">
{t("social.bluesky.handle")}
</div>
</a>
<a
href={t("social.gitlab.url")}
className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer group"
aria-label={t("social.gitlab.ariaLabel")}
{t("navigation.useCases")}
</Link>
<Link
href="/learn"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- social icon */}
<img
src={getAssetPath(ASSETS.GITLAB_ICON)}
alt="GitLab"
width={22}
height={22}
className="flex-shrink-0 grayscale group-hover:scale-110 transition-transform"
/>
<div className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal">
{t("social.gitlab.handle")}
</div>
</a>
</div>
</div>
{/* Links Section */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)] order-1 sm:order-2">
<Link
href="#"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
{t("navigation.useCases")}
</Link>
<Link
href="/learn"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
{t("navigation.learn")}
</Link>
<Link
href="#"
className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
{t("navigation.about")}
</Link>
{t("navigation.learn")}
</Link>
<Link
href="#"
className={`w-full text-left ${primaryLinkClass} md:w-auto md:text-right`}
>
{t("navigation.about")}
</Link>
</nav>
</div>
</div>
<Separator />
{/* Legal Links */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)] sm:flex-row sm:gap-[var(--spacing-measures-spacing-024,24px)]">
<Link
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6 hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
<div
className="flex w-full flex-col
gap-[var(--spacing-scale-032)]
text-[var(--color-content-default-primary)]
md:flex-row md:items-start md:justify-between md:gap-[var(--spacing-scale-040)]
md:whitespace-nowrap
md:text-xs md:leading-4
lg:text-sm lg:leading-5"
>
<p
className="w-full font-inter text-sm font-normal leading-5 tracking-[0%]
text-[var(--color-content-default-secondary)]
md:w-auto
md:text-xs md:leading-4
lg:text-sm lg:leading-5"
>
{t("legal.privacyPolicy")}
</Link>
<Link
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6 hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
{t("copyright")}
</p>
<div
className="flex w-full min-w-0 flex-col flex-wrap
gap-[var(--spacing-scale-032)]
font-inter text-sm
text-[var(--color-content-default-primary)]
md:max-w-[min(100%,396px)]
md:flex-row md:flex-nowrap md:content-center md:items-center md:justify-end
md:gap-[var(--spacing-scale-040)]
md:text-xs md:leading-4
lg:max-w-none
lg:gap-10
lg:text-sm lg:leading-5"
>
{t("legal.termsOfService")}
</Link>
<Link
href="#"
className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6 hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer"
>
{t("legal.cookiesSettings")}
</Link>
</div>
{/* Copyright */}
<div className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6">
{t("copyright")}
<Link href="#" className={legalLinkClass}>
{t("legal.privacyPolicy")}
</Link>
<Link href="#" className={legalLinkClass}>
{t("legal.termsOfService")}
</Link>
<Link href="#" className={legalLinkClass}>
{t("legal.cookiesSettings")}
</Link>
</div>
</div>
</div>
</footer>
@@ -0,0 +1,58 @@
"use client";
import { memo } from "react";
import LinkView from "./Link.view";
import type { LinkProps } from "./Link.types";
/**
* Figma: "Link" in Navigation — "Link, CTA" (21861:21428). Paragraph uses the
* same border-b + pb-0.5 spacing as default, with the rule visible at rest.
*/
const Link = memo<LinkProps>(
({
children,
className = "",
type: linkType = "primary",
variant = "default",
theme = "light",
leadingIcon = true,
trailingIcon = true,
href,
onClick,
prefetch,
replace,
scroll,
rel,
target,
id,
"aria-label": ariaLabel,
"aria-current": ariaCurrent,
}) => {
return (
<LinkView
className={className}
type={linkType}
variant={variant}
theme={theme}
leadingIcon={variant === "default" ? leadingIcon : false}
trailingIcon={variant === "default" ? trailingIcon : false}
href={href}
onClick={onClick}
prefetch={prefetch}
replace={replace}
scroll={scroll}
rel={rel}
target={target}
id={id}
aria-label={ariaLabel}
aria-current={ariaCurrent}
>
{children}
</LinkView>
);
},
);
Link.displayName = "Link";
export default Link;
@@ -0,0 +1,61 @@
import type { AriaAttributes, ReactNode } from "react";
export const LINK_TYPE_OPTIONS = ["primary", "secondary"] as const;
export type LinkTypeValue = (typeof LINK_TYPE_OPTIONS)[number];
export const LINK_VARIANT_OPTIONS = ["default", "paragraph"] as const;
export type LinkVariantValue = (typeof LINK_VARIANT_OPTIONS)[number];
export const LINK_THEME_OPTIONS = ["light", "dark"] as const;
export type LinkThemeValue = (typeof LINK_THEME_OPTIONS)[number];
/**
* Figma: "Link" in Navigation — `21861:21428`. Interaction states are
* implemented with CSS; there is no `state` prop.
*/
export type LinkProps = {
children: ReactNode;
className?: string;
/** Figma: Type (primary or secondary). */
type?: LinkTypeValue;
/** Figma: default (with icons) or paragraph (underlined). */
variant?: LinkVariantValue;
/** Figma: light or dark surface. */
theme?: LinkThemeValue;
/** Figma "default" variant: 16px plus before text. Ignored for `paragraph`. */
leadingIcon?: boolean;
/** Figma "default" variant: 16px plus after text. Ignored for `paragraph`. */
trailingIcon?: boolean;
href?: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => void;
/** Passed to `next/link` when `href` is set. */
prefetch?: boolean;
replace?: boolean;
scroll?: boolean;
rel?: string;
target?: string;
id?: string;
"aria-label"?: string;
"aria-current"?: AriaAttributes["aria-current"];
};
export type LinkViewProps = {
children: ReactNode;
className?: string;
type: LinkTypeValue;
variant: LinkVariantValue;
theme: LinkThemeValue;
leadingIcon: boolean;
trailingIcon: boolean;
href?: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => void;
dataFigmaNode?: string;
prefetch?: boolean;
replace?: boolean;
scroll?: boolean;
rel?: string;
target?: string;
id?: string;
"aria-label"?: string;
"aria-current"?: AriaAttributes["aria-current"];
};
@@ -0,0 +1,192 @@
"use client";
import NextLink from "next/link";
import { memo } from "react";
import type { MouseEventHandler, ReactNode } from "react";
import type { LinkTypeValue, LinkViewProps, LinkThemeValue, LinkVariantValue } from "./Link.types";
const FIGMA_ROOT = "21861:21428";
/** Profile & card small viewports: Figma Sizing/300 + label line (350). ≥640px: 18px / 1.3. */
const LINK_TYPOGRAPHY =
"font-inter font-normal text-[length:var(--sizing-300)] leading-[var(--sizing-350)] min-[640px]:text-[18px] min-[640px]:leading-[1.3]";
function linkFocusRing(theme: LinkThemeValue) {
return theme === "light"
? "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--color-border-link-focus)] focus-visible:rounded-lg"
: "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--color-border-link-invert-focus)] focus-visible:rounded-lg";
}
function defaultRootClass(theme: LinkThemeValue, linkType: LinkTypeValue) {
const focusRing = linkFocusRing(theme);
if (theme === "light" && linkType === "primary") {
return `group inline-flex min-h-8 max-h-16 w-fit max-w-full shrink-0 items-center gap-2 rounded-lg ${LINK_TYPOGRAPHY} text-[var(--color-link-primary)] hover:text-[var(--color-link-primary-hover)] focus-visible:text-[var(--color-link-primary-focus)] active:text-[var(--color-link-primary-active)] ${focusRing}`;
}
if (theme === "light" && linkType === "secondary") {
return `group inline-flex min-h-8 max-h-16 w-fit max-w-full shrink-0 items-center gap-2 rounded-lg ${LINK_TYPOGRAPHY} text-[var(--color-link-secondary)] hover:text-[var(--color-link-secondary-hover)] focus-visible:text-[var(--color-link-secondary-focus)] active:text-[var(--color-link-secondary-active)] ${focusRing}`;
}
if (theme === "dark" && linkType === "primary") {
return `group inline-flex min-h-8 max-h-16 w-fit max-w-full shrink-0 items-center gap-2 rounded-lg ${LINK_TYPOGRAPHY} text-[var(--color-link-invert-primary)] hover:text-[var(--color-link-invert-primary-hover)] focus-visible:text-[var(--color-link-invert-primary-focus)] active:text-[var(--color-link-invert-primary-active)] ${focusRing}`;
}
return `group inline-flex min-h-8 max-h-16 w-fit max-w-full shrink-0 items-center gap-2 rounded-lg ${LINK_TYPOGRAPHY} text-[var(--color-link-invert-secondary)] hover:text-[var(--color-link-invert-secondary-hover)] focus-visible:text-[var(--color-link-invert-secondary-focus)] active:text-[var(--color-link-invert-secondary-active)] ${focusRing}`;
}
function defaultUnderlineClass(theme: LinkThemeValue, linkType: LinkTypeValue) {
if (theme === "light" && linkType === "primary") {
return "inline-block min-w-0 max-w-full border-b border-transparent bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-primary-hover)] group-focus-visible:border-[var(--color-link-primary-focus)] group-active:border-[var(--color-link-primary-active)]";
}
if (theme === "light" && linkType === "secondary") {
return "inline-block min-w-0 max-w-full border-b border-transparent bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-secondary-hover)] group-focus-visible:border-[var(--color-link-secondary-focus)] group-active:border-[var(--color-link-secondary-active)]";
}
if (theme === "dark" && linkType === "primary") {
return "inline-block min-w-0 max-w-full border-b border-transparent bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-invert-primary-hover)] group-focus-visible:border-[var(--color-link-invert-primary-focus)] group-active:border-[var(--color-link-invert-primary-active)]";
}
return "inline-block min-w-0 max-w-full border-b border-transparent bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-invert-secondary-hover)] group-focus-visible:border-[var(--color-link-invert-secondary-focus)] group-active:border-[var(--color-link-invert-secondary-active)]";
}
/** Same `pb-0.5` + `border-b` as default, but the rule is visible at rest. */
function paragraphUnderlineClass(theme: LinkThemeValue, linkType: LinkTypeValue) {
if (theme === "light" && linkType === "primary") {
return "inline-block min-w-0 max-w-full border-b border-[var(--color-link-primary)] bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-primary-hover)] group-focus-visible:border-[var(--color-link-primary-focus)] group-active:border-[var(--color-link-primary-active)]";
}
if (theme === "light" && linkType === "secondary") {
return "inline-block min-w-0 max-w-full border-b border-[var(--color-link-secondary)] bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-secondary-hover)] group-focus-visible:border-[var(--color-link-secondary-focus)] group-active:border-[var(--color-link-secondary-active)]";
}
if (theme === "dark" && linkType === "primary") {
return "inline-block min-w-0 max-w-full border-b border-[var(--color-link-invert-primary)] bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-invert-primary-hover)] group-focus-visible:border-[var(--color-link-invert-primary-focus)] group-active:border-[var(--color-link-invert-primary-active)]";
}
return "inline-block min-w-0 max-w-full border-b border-[var(--color-link-invert-secondary)] bg-transparent px-0 pb-0.5 text-left text-inherit transition-[border-color] group-hover:border-[var(--color-link-invert-secondary-hover)] group-focus-visible:border-[var(--color-link-invert-secondary-focus)] group-active:border-[var(--color-link-invert-secondary-active)]";
}
function LinkPlus12() {
return (
<span className="inline-flex size-4 shrink-0 items-center justify-center text-inherit" aria-hidden>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden
>
<path
d="M5.25 0h1.5v4.5H12v1.5H6.75V12h-1.5V6.75H0V5.25h5.25V0Z"
fill="currentColor"
/>
</svg>
</span>
);
}
function LinkViewInner({
variant,
theme,
type,
leadingIcon,
trailingIcon,
children,
}: {
variant: LinkVariantValue;
theme: LinkThemeValue;
type: LinkTypeValue;
leadingIcon: boolean;
trailingIcon: boolean;
children: ReactNode;
}) {
if (variant === "paragraph") {
return (
<span className={`min-h-0 min-w-0 max-w-full shrink ${paragraphUnderlineClass(theme, type)}`}>
<span className="block min-w-0 whitespace-normal [overflow-wrap:anywhere] text-inherit">
{children}
</span>
</span>
);
}
return (
<>
{leadingIcon ? <LinkPlus12 /> : null}
<span className={`min-h-0 min-w-0 max-w-full shrink ${defaultUnderlineClass(theme, type)}`}>
<span className="block min-w-0 whitespace-normal [overflow-wrap:anywhere] text-inherit">
{children}
</span>
</span>
{trailingIcon ? <LinkPlus12 /> : null}
</>
);
}
function LinkView({
children,
className,
type,
variant,
theme,
leadingIcon,
trailingIcon,
href,
onClick,
dataFigmaNode = FIGMA_ROOT,
prefetch,
replace,
scroll,
rel,
target,
id,
"aria-label": ariaLabel,
"aria-current": ariaCurrent,
}: LinkViewProps) {
const root = [defaultRootClass(theme, type), className]
.filter(Boolean)
.join(" ");
const content = (
<LinkViewInner
variant={variant}
theme={theme}
type={type}
leadingIcon={leadingIcon}
trailingIcon={trailingIcon}
>
{children}
</LinkViewInner>
);
if (href) {
return (
<NextLink
href={href}
className={root}
data-figma-node={dataFigmaNode}
id={id}
aria-label={ariaLabel}
aria-current={ariaCurrent}
prefetch={prefetch}
replace={replace}
scroll={scroll}
rel={rel}
target={target}
onClick={onClick as MouseEventHandler<HTMLAnchorElement> | undefined}
>
{content}
</NextLink>
);
}
return (
<button
type="button"
className={`${root} m-0 cursor-pointer border-0 bg-transparent p-0 text-left font-inherit [font-family:inherit]`}
data-figma-node={dataFigmaNode}
id={id}
aria-label={ariaLabel}
aria-current={ariaCurrent}
onClick={onClick as MouseEventHandler<HTMLButtonElement> | undefined}
>
{content}
</button>
);
}
LinkView.displayName = "LinkView";
export default memo(LinkView);
+7
View File
@@ -0,0 +1,7 @@
export { default } from "./Link.container";
export type { LinkProps, LinkTypeValue, LinkVariantValue, LinkThemeValue } from "./Link.types";
export {
LINK_TYPE_OPTIONS,
LINK_VARIANT_OPTIONS,
LINK_THEME_OPTIONS,
} from "./Link.types";
@@ -162,9 +162,17 @@ function TopNavView({
aria-label={t("ariaLabels.mainNavigationHeader")}
>
<nav
className="flex items-center gap-[var(--spacing-scale-002)] sm:justify-between mx-auto h-[var(--spacing-scale-040)] lg:h-[84px] xl:h-[88px] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] sm:px-[var(--spacing-measures-spacing-016)] sm:py-[var(--spacing-measures-spacing-008)] lg:px-[var(--spacing-measures-spacing-64,64px)] lg:py-[var(--spacing-measures-spacing-016,16px)] sm:gap-0"
role="navigation"
aria-label={t("ariaLabels.mainNavigation")}
className="flex items-center gap-[var(--spacing-scale-002)] sm:justify-between mx-auto
h-[var(--spacing-scale-040)]
lg:h-auto
px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)]
sm:px-[var(--spacing-measures-spacing-016)] sm:py-[var(--spacing-measures-spacing-008)]
lg:px-[var(--spacing-measures-spacing-64,64px)]
lg:py-[var(--spacing-scale-020)]
xl:py-[var(--spacing-scale-024)]
sm:gap-0"
role="navigation"
aria-label={t("ariaLabels.mainNavigation")}
>
{/* Logo - Consistent left positioning across all breakpoints */}
<Logo
@@ -11,6 +11,7 @@ const HeaderLockupContainer = memo<HeaderLockupProps>(
justification: justificationProp = "left",
size: sizeProp = "L",
palette: paletteProp = "default",
titleId,
}) => {
const justification = justificationProp;
const size = sizeProp;
@@ -23,6 +24,7 @@ const HeaderLockupContainer = memo<HeaderLockupProps>(
justification={justification}
size={size}
palette={palette}
titleId={titleId}
/>
);
},
@@ -25,6 +25,10 @@ export interface HeaderLockupProps {
* Palette. default = light text (dark bg); inverse = dark text (light bg).
*/
palette?: HeaderLockupPaletteValue;
/**
* Optional DOM id for the title `h1` (e.g. skip-link / `aria-labelledby` targets).
*/
titleId?: string;
}
export interface HeaderLockupViewProps {
@@ -33,4 +37,5 @@ export interface HeaderLockupViewProps {
justification: "left" | "center";
size: "L" | "M";
palette: "default" | "inverse";
titleId?: string;
}
@@ -9,6 +9,7 @@ function HeaderLockupView({
justification,
size,
palette,
titleId,
}: HeaderLockupViewProps) {
const isL = size === "L";
const isLeft = justification === "left";
@@ -30,6 +31,7 @@ function HeaderLockupView({
{/* Title */}
<div className="flex items-center relative shrink-0 w-full">
<h1
id={titleId}
className={`flex-[1_0_0] min-h-px min-w-px overflow-hidden relative ${titleColorClass} text-ellipsis whitespace-pre-wrap ${
isLeft ? "text-left" : "text-center"
} ${
@@ -0,0 +1,17 @@
"use client";
import { memo } from "react";
import { DividerView } from "./Divider.view";
import type { DividerProps } from "./Divider.types";
/**
* Figma: "Utility / Divider" (450:1941). Content vs Menu line weight; horizontal
* or vertical.
*/
const DividerContainer = memo<DividerProps>((props) => {
return <DividerView {...props} />;
});
DividerContainer.displayName = "Divider";
export default DividerContainer;
@@ -0,0 +1,19 @@
export const DIVIDER_ORIENTATION_OPTIONS = ["horizontal", "vertical"] as const;
export type DividerOrientation = (typeof DIVIDER_ORIENTATION_OPTIONS)[number];
export const DIVIDER_TYPE_OPTIONS = ["content", "menu"] as const;
export type DividerType = (typeof DIVIDER_TYPE_OPTIONS)[number];
export type DividerProps = {
/** @default "horizontal" */
orientation?: DividerOrientation;
/**
* Content: `--color-border-default-secondary` (subtle, lists / panels).
* Menu: `--color-border-default-tertiary` (navigation chrome).
* @default "content"
*/
type?: DividerType;
className?: string;
};
export type DividerViewProps = DividerProps;
@@ -0,0 +1,46 @@
"use client";
import { memo } from "react";
import type { DividerViewProps } from "./Divider.types";
const lineColor: Record<"content" | "menu", string> = {
content: "bg-[var(--color-border-default-secondary)]",
menu: "bg-[var(--color-border-default-tertiary)]",
};
/**
* Figma: "Utility / Divider" — horizontal Content (6894:22988), vertical Content
* (6894:22990), Menu horizontal (450:1940), Menu vertical (2002:30943).
*/
export const DividerView = memo(function DividerView({
orientation = "horizontal",
type: dividerType = "content",
className = "",
}: DividerViewProps) {
const color = lineColor[dividerType];
if (orientation === "vertical") {
return (
<div
className={`w-px shrink-0 self-stretch ${color} ${className}`}
data-figma-node={dividerType === "content" ? "6894:22990" : "2002:30943"}
aria-hidden="true"
/>
);
}
return (
<div
className={`flex w-full flex-col items-center ${className}`}
data-figma-node={dividerType === "content" ? "6894:22988" : "450:1940"}
>
<div
className={`h-px w-full shrink-0 ${color}`}
data-figma-node={dividerType === "content" ? "6894:22989" : "2002:30856"}
aria-hidden="true"
/>
</div>
);
});
DividerView.displayName = "DividerView";
+6
View File
@@ -0,0 +1,6 @@
export { default } from "./Divider.container";
export type { DividerProps, DividerOrientation, DividerType } from "./Divider.types";
export {
DIVIDER_ORIENTATION_OPTIONS,
DIVIDER_TYPE_OPTIONS,
} from "./Divider.types";