Profile page UI and functionality implemented
This commit is contained in:
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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,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";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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 modal’s 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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Dialog.container";
|
||||
export type { DialogProps } from "./Dialog.types";
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user