Start organizational migration

This commit is contained in:
adilallo
2026-02-05 18:21:56 -07:00
parent 69074b23f3
commit db3c0274f6
161 changed files with 145 additions and 145 deletions
@@ -0,0 +1,122 @@
"use client";
import { memo } from "react";
import { AlertView } from "./Alert.view";
import type { AlertProps } from "./Alert.types";
import { normalizeAlertStatus, normalizeAlertType } from "../../../../lib/propNormalization";
const AlertContainer = memo<AlertProps>(
({
title,
description,
status: statusProp = "default",
type: typeProp = "toast",
onClose,
className = "",
}) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const status = normalizeAlertStatus(statusProp);
const type = normalizeAlertType(typeProp);
// Determine background and border colors based on status and type
const getStatusStyles = () => {
switch (status) {
case "positive":
return {
background: "bg-[var(--color-kiwi-kiwi0)]",
borderColor:
type === "toast"
? "var(--color-border-invert-positive-primary)"
: undefined,
titleColor: "text-[var(--color-content-invert-primary)]",
descriptionColor: "text-[var(--color-content-invert-secondary)]",
iconColor: "var(--color-kiwi-kiwi500)",
closeButtonIconColor: "var(--color-content-invert-primary)",
};
case "warning":
return {
background: "bg-[var(--color-yellow-yellow0)]",
borderColor:
type === "toast"
? "var(--color-border-invert-warning-primary)"
: undefined,
titleColor: "text-[var(--color-content-invert-primary)]",
descriptionColor: "text-[var(--color-content-invert-secondary)]",
iconColor: "var(--color-yellow-yellow500)",
closeButtonIconColor: "var(--color-content-invert-primary)",
};
case "danger":
return {
background: "bg-[var(--color-red-red0)]",
borderColor:
type === "toast"
? "var(--color-border-invert-negative-primary)"
: undefined,
titleColor: "text-[var(--color-content-invert-negative-primary)]",
descriptionColor:
"text-[var(--color-content-invert-negative-primary)]",
iconColor: "var(--color-red-red500)",
closeButtonIconColor: "var(--color-content-invert-primary)",
};
default:
return {
background: "bg-[var(--color-surface-default-tertiary)]",
borderColor:
type === "toast"
? "var(--color-border-default-primary)"
: undefined,
titleColor: "text-[var(--color-content-default-primary)]",
descriptionColor: "text-[var(--color-content-default-primary)]",
iconColor: "var(--color-content-default-brand-primary)",
closeButtonIconColor: "var(--color-content-default-brand-primary)",
};
}
};
const statusStyles = getStatusStyles();
const containerClasses = `flex gap-[var(--space-300)] items-center ${
type === "toast"
? `pb-[var(--space-500)] pt-[var(--space-400)] px-[var(--space-1200)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)]`
: `px-[var(--spacing-scale-024)] py-[var(--spacing-scale-016)] rounded-[var(--radius-200,8px)]`
} ${statusStyles.background} border-solid`;
const containerStyle =
type === "toast" && statusStyles.borderColor
? {
borderBottomWidth: "var(--border-large)",
borderBottomColor: statusStyles.borderColor,
}
: undefined;
const titleClasses =
type === "banner"
? `font-inter text-[16px] leading-[20px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`
: `font-inter text-[18px] leading-[24px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`;
const descriptionClasses =
type === "banner"
? `font-inter text-[16px] leading-[24px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`
: `font-inter text-[18px] leading-[23.4px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`;
return (
<AlertView
title={title}
description={description}
status={status}
type={type}
className={className}
containerClasses={containerClasses}
containerStyle={containerStyle}
titleClasses={titleClasses}
descriptionClasses={descriptionClasses}
iconColor={statusStyles.iconColor}
closeButtonIconColor={statusStyles.closeButtonIconColor}
onClose={onClose}
/>
);
},
);
AlertContainer.displayName = "Alert";
export default AlertContainer;
@@ -0,0 +1,43 @@
export type AlertStatusValue =
| "default"
| "positive"
| "warning"
| "danger"
| "Default"
| "Positive"
| "Warning"
| "Danger";
export type AlertTypeValue = "toast" | "banner" | "Toast" | "Banner";
export interface AlertProps {
title: string;
description?: string;
/**
* Alert status. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
status?: AlertStatusValue;
/**
* Alert type. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
type?: AlertTypeValue;
onClose?: () => void;
className?: string;
}
export interface AlertViewProps {
title: string;
description?: string;
status: "default" | "positive" | "warning" | "danger";
type: "toast" | "banner";
className: string;
containerClasses: string;
containerStyle?: React.CSSProperties;
titleClasses: string;
descriptionClasses: string;
iconColor: string;
closeButtonIconColor: string;
onClose?: () => void;
}
@@ -0,0 +1,86 @@
import type { AlertViewProps } from "./Alert.types";
import Button from "../../buttons/Button";
export function AlertView({
title,
description,
status: _status,
type: _type,
className,
containerClasses,
containerStyle,
titleClasses,
descriptionClasses,
iconColor,
closeButtonIconColor,
onClose,
}: AlertViewProps) {
const getIcon = () => {
// Use the Icon_Alert.svg with dynamic fill color
// The SVG has a fill that we'll override with the iconColor
// Icon is 19x19px with 2.5px spacing in a 24x24px bounding box
return (
<svg
width="19"
height="19"
viewBox="0 0 19 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.49998 14.2307C9.72883 14.2307 9.92065 14.1533 10.0755 13.9985C10.2303 13.8437 10.3077 13.6519 10.3077 13.4231C10.3077 13.1942 10.2303 13.0024 10.0755 12.8476C9.92065 12.6928 9.72883 12.6154 9.49998 12.6154C9.27112 12.6154 9.0793 12.6928 8.9245 12.8476C8.7697 13.0024 8.6923 13.1942 8.6923 13.4231C8.6923 13.6519 8.7697 13.8437 8.9245 13.9985C9.0793 14.1533 9.27112 14.2307 9.49998 14.2307ZM8.75 10.5769H10.25V4.5769H8.75V10.5769ZM9.50165 19C8.18772 19 6.95268 18.7506 5.79655 18.252C4.6404 17.7533 3.63472 17.0765 2.7795 16.2217C1.92427 15.3669 1.24721 14.3616 0.748325 13.206C0.249442 12.0504 0 10.8156 0 9.50165C0 8.18772 0.249334 6.95268 0.748 5.79655C1.24667 4.6404 1.92342 3.63472 2.77825 2.7795C3.6331 1.92427 4.63834 1.24721 5.79398 0.748326C6.94959 0.249443 8.18437 0 9.4983 0C10.8122 0 12.0473 0.249334 13.2034 0.748001C14.3596 1.24667 15.3652 1.92342 16.2205 2.77825C17.0757 3.6331 17.7527 4.63834 18.2516 5.79398C18.7505 6.94959 19 8.18437 19 9.4983C19 10.8122 18.7506 12.0473 18.252 13.2034C17.7533 14.3596 17.0765 15.3652 16.2217 16.2205C15.3669 17.0757 14.3616 17.7527 13.206 18.2516C12.0504 18.7505 10.8156 19 9.50165 19ZM9.49998 17.5C11.7333 17.5 13.625 16.725 15.175 15.175C16.725 13.625 17.5 11.7333 17.5 9.49998C17.5 7.26664 16.725 5.37498 15.175 3.82498C13.625 2.27498 11.7333 1.49998 9.49998 1.49998C7.26664 1.49998 5.37498 2.27498 3.82498 3.82498C2.27498 5.37498 1.49998 7.26664 1.49998 9.49998C1.49998 11.7333 2.27498 13.625 3.82498 15.175C5.37498 16.725 7.26664 17.5 9.49998 17.5Z"
fill={iconColor}
/>
</svg>
);
};
return (
<div
className={`${containerClasses} ${className}`}
style={containerStyle}
role="alert"
>
<div className="shrink-0 w-[24px] h-[24px] flex items-center justify-center">
{getIcon()}
</div>
<div className="flex flex-1 flex-col items-start justify-center min-h-0 min-w-0">
<p className={titleClasses}>{title}</p>
{description && <p className={descriptionClasses}>{description}</p>}
</div>
<Button
variant="ghost"
size="large"
onClick={onClose}
ariaLabel="Close alert"
className="shrink-0 [&_svg_path]:transition-colors [&_svg_path]:duration-200 hover:[&_svg_path]:fill-[var(--color-content-default-primary)]"
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_21296_8285"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_21296_8285)">
<path
d="M5.33327 15.5448L4.45508 14.6666L9.12174 9.99993L4.45508 5.33327L5.33327 4.45508L9.99993 9.12174L14.6666 4.45508L15.5448 5.33327L10.8781 9.99993L15.5448 14.6666L14.6666 15.5448L9.99993 10.8781L5.33327 15.5448Z"
fill={closeButtonIconColor}
/>
</g>
</svg>
</Button>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Alert.container";
export type { AlertProps } from "./Alert.types";
@@ -0,0 +1,140 @@
"use client";
import { memo, useEffect, useRef } from "react";
import { CreateView } from "./Create.view";
import type { CreateProps } from "./Create.types";
const CreateContainer = memo<CreateProps>(
({
isOpen,
onClose,
title,
description,
children,
footerContent,
showBackButton = true,
showNextButton = true,
onBack,
onNext,
backButtonText = "Back",
nextButtonText = "Next",
nextButtonDisabled = false,
currentStep,
totalSteps,
className = "",
ariaLabel,
ariaLabelledBy,
}) => {
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]);
return (
<CreateView
isOpen={isOpen}
onClose={onClose}
title={title}
description={description}
// eslint-disable-next-line react/no-children-prop
children={children}
footerContent={footerContent}
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
onNext={onNext}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
createRef={createRef}
overlayRef={overlayRef}
/>
);
},
);
CreateContainer.displayName = "Create";
export default CreateContainer;
@@ -0,0 +1,43 @@
export interface CreateProps {
isOpen: boolean;
onClose: () => void;
title?: string;
description?: string;
children?: React.ReactNode;
footerContent?: React.ReactNode;
showBackButton?: boolean;
showNextButton?: boolean;
onBack?: () => void;
onNext?: () => void;
backButtonText?: string;
nextButtonText?: string;
nextButtonDisabled?: boolean;
currentStep?: number;
totalSteps?: number;
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
}
export interface CreateViewProps {
isOpen: boolean;
onClose: () => void;
title?: string;
description?: string;
children?: React.ReactNode;
footerContent?: React.ReactNode;
showBackButton: boolean;
showNextButton: boolean;
onBack?: () => void;
onNext?: () => void;
backButtonText: string;
nextButtonText: string;
nextButtonDisabled: boolean;
currentStep?: number;
totalSteps?: number;
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
createRef: React.RefObject<HTMLDivElement>;
overlayRef: React.RefObject<HTMLDivElement>;
}
@@ -0,0 +1,95 @@
"use client";
import { createPortal } from "react-dom";
import ContentLockup from "../../type/ContentLockup";
import ModalFooter from "../../utility/ModalFooter";
import ModalHeader from "../../utility/ModalHeader";
import type { CreateViewProps } from "./Create.types";
export function CreateView({
isOpen,
onClose,
title,
description,
children,
footerContent,
showBackButton,
showNextButton,
onBack,
onNext,
backButtonText,
nextButtonText,
nextButtonDisabled,
currentStep,
totalSteps,
className,
ariaLabel,
ariaLabelledBy,
createRef,
overlayRef,
}: CreateViewProps) {
if (!isOpen) return null;
const createContent = (
<>
{/* Overlay */}
<div
ref={overlayRef}
className="fixed inset-0 bg-black/50 z-[9998]"
onClick={onClose}
aria-hidden="true"
/>
{/* Create Dialog */}
<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-[728px] flex flex-col overflow-hidden z-[9999] ${className}`}
>
{/* Header with close buttons */}
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
{/* Header Lockup Section (Sticky) */}
{(title || description) && (
<div className="bg-[var(--color-surface-default-primary)] px-[24px] py-[12px] shrink-0 sticky top-[48px] z-[2]">
<ContentLockup
title={title}
description={description}
variant="modal"
alignment="left"
/>
</div>
)}
{/* Content Area (Scrollable) */}
<div className="flex flex-col gap-[var(--spacing-scale-024)] px-[24px] pb-[96px] overflow-x-clip overflow-y-auto relative shrink-0 flex-1">
{children}
</div>
{/* Footer */}
<ModalFooter
showBackButton={showBackButton}
showNextButton={showNextButton}
onBack={onBack}
onNext={onNext}
backButtonText={backButtonText}
nextButtonText={nextButtonText}
nextButtonDisabled={nextButtonDisabled}
currentStep={currentStep}
totalSteps={totalSteps}
footerContent={footerContent}
/>
</div>
</>
);
// Portal to body
if (typeof window !== "undefined") {
return createPortal(createContent, document.body);
}
return null;
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Create.container";
export type { CreateProps } from "./Create.types";
@@ -0,0 +1,52 @@
"use client";
import { memo, useState } from "react";
import { TooltipView } from "./Tooltip.view";
import type { TooltipProps } from "./Tooltip.types";
import { normalizeTooltipPosition } from "../../../../lib/propNormalization";
const TooltipContainer = memo<TooltipProps>(
({ children, text, position: positionProp = "top", className = "", disabled = false }) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const position = normalizeTooltipPosition(positionProp);
const [isVisible, setIsVisible] = useState(false);
if (disabled) {
return <>{children}</>;
}
const tooltipClasses = `absolute z-50 bg-[var(--color-surface-default-primary)] px-[var(--space-300)] py-[var(--space-200)] rounded-[var(--radius-300,12px)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] flex items-center whitespace-nowrap ${
position === "top" ? "bottom-full mb-[7px]" : "top-full mt-[7px]"
} left-1/2 -translate-x-1/2 ${isVisible ? "opacity-100 visible" : "opacity-0 invisible pointer-events-none"} transition-all duration-200`;
// Pointer positioning: 10px tall, 7px sticks out, 3px inside tooltip
// For bottom tooltip: pointer at top, pointing up, 7px above tooltip
// For top tooltip: pointer at bottom, pointing down, 7px below tooltip
const pointerClasses = `absolute ${
position === "top" ? "bottom-[-7px]" : "top-[-7px]"
} left-1/2 -translate-x-1/2`;
return (
<div
className={`relative inline-block ${className}`}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
<TooltipView
text={text}
position={position}
className=""
tooltipClasses={tooltipClasses}
pointerClasses={pointerClasses}
/>
</div>
);
},
);
TooltipContainer.displayName = "Tooltip";
export default TooltipContainer;
@@ -0,0 +1,21 @@
export type TooltipPositionValue = "top" | "bottom" | "Top" | "Bottom";
export interface TooltipProps {
children: React.ReactNode;
text: string;
/**
* Tooltip position. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
position?: TooltipPositionValue;
className?: string;
disabled?: boolean;
}
export interface TooltipViewProps {
text: string;
position: "top" | "bottom";
className: string;
tooltipClasses: string;
pointerClasses: string;
}
@@ -0,0 +1,45 @@
import type { TooltipViewProps } from "./Tooltip.types";
export function TooltipView({
text,
position,
className: _className,
tooltipClasses,
pointerClasses,
}: TooltipViewProps) {
// Pointer is 10px tall with 7px sticking out
// Icon_Pointer.svg is 14x8, scale to 10px height = 17.5px width
const pointerWidth = 17.5;
const pointerHeight = 10;
const pointerRotation = position === "top" ? "rotate-180" : "rotate-0";
return (
<div
className={tooltipClasses}
role="tooltip"
aria-live="polite"
id={`tooltip-${text.replace(/\s+/g, "-").toLowerCase()}`}
>
<p className="font-inter text-[var(--sizing-350,14px)] leading-[16px] font-medium tracking-[0%] text-[var(--color-content-default-primary)] relative shrink-0">
{text}
</p>
<div
className={pointerClasses}
style={{ width: `${pointerWidth}px`, height: `${pointerHeight}px` }}
>
<svg
className={`${pointerRotation} w-full h-full`}
viewBox="0 0 14 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M6.92822 0L13.8564 7.5H1.95503e-05L6.92822 0Z"
fill="var(--color-surface-default-primary)"
/>
</svg>
</div>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Tooltip.container";
export type { TooltipProps } from "./Tooltip.types";