From abe4bff09ee889f0f316d9d83e3d474e114d2f82 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:24:03 -0700 Subject: [PATCH] Implement Alert and Tooltip components --- app/components-preview/page.tsx | 184 +++++++++++++++++++ app/components/Alert/Alert.container.tsx | 124 +++++++++++++ app/components/Alert/Alert.types.ts | 26 +++ app/components/Alert/Alert.view.tsx | 86 +++++++++ app/components/Alert/index.tsx | 2 + app/components/Tooltip/Tooltip.container.tsx | 57 ++++++ app/components/Tooltip/Tooltip.types.ts | 15 ++ app/components/Tooltip/Tooltip.view.tsx | 45 +++++ app/components/Tooltip/index.tsx | 2 + lib/assetUtils.ts | 7 + public/assets/Icon_Alert.svg | 3 + public/assets/Icon_Close.svg | 8 + public/assets/Icon_Pointer.svg | 3 + stories/Alert.stories.js | 154 ++++++++++++++++ stories/Tooltip.stories.js | 85 +++++++++ tests/components/Alert.test.tsx | 28 +++ tests/components/Tooltip.test.tsx | 60 ++++++ 17 files changed, 889 insertions(+) create mode 100644 app/components-preview/page.tsx create mode 100644 app/components/Alert/Alert.container.tsx create mode 100644 app/components/Alert/Alert.types.ts create mode 100644 app/components/Alert/Alert.view.tsx create mode 100644 app/components/Alert/index.tsx create mode 100644 app/components/Tooltip/Tooltip.container.tsx create mode 100644 app/components/Tooltip/Tooltip.types.ts create mode 100644 app/components/Tooltip/Tooltip.view.tsx create mode 100644 app/components/Tooltip/index.tsx create mode 100644 public/assets/Icon_Alert.svg create mode 100644 public/assets/Icon_Close.svg create mode 100644 public/assets/Icon_Pointer.svg create mode 100644 stories/Alert.stories.js create mode 100644 stories/Tooltip.stories.js create mode 100644 tests/components/Alert.test.tsx create mode 100644 tests/components/Tooltip.test.tsx diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx new file mode 100644 index 0000000..c0d241e --- /dev/null +++ b/app/components-preview/page.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState } from "react"; +import Tooltip from "../components/Tooltip"; +import Alert from "../components/Alert"; +import Button from "../components/Button"; + +export default function ComponentsPreview() { + const [alertVisible, setAlertVisible] = useState({ + default: true, + positive: true, + warning: true, + danger: true, + banner: true, + }); + + return ( +
+
+
+

+ Component Preview +

+

+ Temporary page for viewing and testing new components +

+
+ + {/* Tooltip Section */} +
+

+ Tooltip Component +

+ +
+
+ + + + + + + + + + + + + + + +
+
+
+ + {/* Alert Section */} +
+

+ Alert Component +

+ +
+ {/* Toast Alerts */} +
+

+ Toast Alerts +

+ + {alertVisible.default && ( + + setAlertVisible({ ...alertVisible, default: false }) + } + /> + )} + + {alertVisible.positive && ( + + setAlertVisible({ ...alertVisible, positive: false }) + } + /> + )} + + {alertVisible.warning && ( + + setAlertVisible({ ...alertVisible, warning: false }) + } + /> + )} + + {alertVisible.danger && ( + + setAlertVisible({ ...alertVisible, danger: false }) + } + /> + )} +
+ + {/* Banner Alerts */} +
+

+ Banner Alerts +

+ + {alertVisible.banner && ( + + setAlertVisible({ ...alertVisible, banner: false }) + } + /> + )} + + + + + + +
+
+
+
+
+ ); +} diff --git a/app/components/Alert/Alert.container.tsx b/app/components/Alert/Alert.container.tsx new file mode 100644 index 0000000..b937ddf --- /dev/null +++ b/app/components/Alert/Alert.container.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { memo } from "react"; +import { AlertView } from "./Alert.view"; +import type { AlertProps } from "./Alert.types"; + +const AlertContainer = memo( + ({ + title, + description, + status = "default", + type = "toast", + onClose, + className = "", + }) => { + // 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)", + closeButtonColor: "text-[var(--color-content-invert-primary)]", + 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)", + closeButtonColor: "text-[var(--color-content-invert-primary)]", + 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)", + closeButtonColor: "text-[var(--color-content-invert-negative-primary)]", + 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)", + closeButtonColor: "text-[var(--color-content-default-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)]`; + + const closeButtonClasses = `flex gap-[var(--spacing-scale-006)] items-center justify-center overflow-clip p-[var(--spacing-scale-012)] rounded-[var(--radius-full)] shrink-0 hover:bg-[var(--color-surface-default-secondary)] transition-colors ${statusStyles.closeButtonColor}`; + + return ( + + ); + }, +); + +AlertContainer.displayName = "Alert"; + +export default AlertContainer; diff --git a/app/components/Alert/Alert.types.ts b/app/components/Alert/Alert.types.ts new file mode 100644 index 0000000..1515d5f --- /dev/null +++ b/app/components/Alert/Alert.types.ts @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +export interface AlertProps { + title: string; + description?: string; + status?: "default" | "positive" | "warning" | "danger"; + type?: "toast" | "banner"; + 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; + closeButtonClasses: string; + closeButtonIconColor: string; + onClose?: () => void; +} diff --git a/app/components/Alert/Alert.view.tsx b/app/components/Alert/Alert.view.tsx new file mode 100644 index 0000000..2a49bbb --- /dev/null +++ b/app/components/Alert/Alert.view.tsx @@ -0,0 +1,86 @@ +import { getAssetPath, ASSETS } from "../../lib/assetUtils"; +import type { AlertViewProps } from "./Alert.types"; + +export function AlertView({ + title, + description, + status, + type, + className, + containerClasses, + containerStyle, + titleClasses, + descriptionClasses, + iconColor, + closeButtonClasses, + 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 ( + + + + ); + }; + + return ( +
+
+ {getIcon()} +
+
+

{title}

+ {description &&

{description}

} +
+ +
+ ); +} diff --git a/app/components/Alert/index.tsx b/app/components/Alert/index.tsx new file mode 100644 index 0000000..9f65e51 --- /dev/null +++ b/app/components/Alert/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Alert.container"; +export type { AlertProps } from "./Alert.types"; diff --git a/app/components/Tooltip/Tooltip.container.tsx b/app/components/Tooltip/Tooltip.container.tsx new file mode 100644 index 0000000..b58d37b --- /dev/null +++ b/app/components/Tooltip/Tooltip.container.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { memo, useState } from "react"; +import { TooltipView } from "./Tooltip.view"; +import type { TooltipProps } from "./Tooltip.types"; + +const TooltipContainer = memo( + ({ + children, + text, + position = "top", + className = "", + disabled = false, + }) => { + 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`; + + const tooltipId = `tooltip-${text.replace(/\s+/g, "-").toLowerCase()}`; + + return ( +
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + onFocus={() => setIsVisible(true)} + onBlur={() => setIsVisible(false)} + > + {children} + +
+ ); + }, +); + +TooltipContainer.displayName = "Tooltip"; + +export default TooltipContainer; diff --git a/app/components/Tooltip/Tooltip.types.ts b/app/components/Tooltip/Tooltip.types.ts new file mode 100644 index 0000000..b746924 --- /dev/null +++ b/app/components/Tooltip/Tooltip.types.ts @@ -0,0 +1,15 @@ +export interface TooltipProps { + children: React.ReactNode; + text: string; + position?: "top" | "bottom"; + className?: string; + disabled?: boolean; +} + +export interface TooltipViewProps { + text: string; + position: "top" | "bottom"; + className: string; + tooltipClasses: string; + pointerClasses: string; +} diff --git a/app/components/Tooltip/Tooltip.view.tsx b/app/components/Tooltip/Tooltip.view.tsx new file mode 100644 index 0000000..e5e13fd --- /dev/null +++ b/app/components/Tooltip/Tooltip.view.tsx @@ -0,0 +1,45 @@ +import type { TooltipViewProps } from "./Tooltip.types"; + +export function TooltipView({ + text, + position, + 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 ( + + ); +} diff --git a/app/components/Tooltip/index.tsx b/app/components/Tooltip/index.tsx new file mode 100644 index 0000000..e85f0ee --- /dev/null +++ b/app/components/Tooltip/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Tooltip.container"; +export type { TooltipProps } from "./Tooltip.types"; diff --git a/lib/assetUtils.ts b/lib/assetUtils.ts index 074c9dd..95087b1 100644 --- a/lib/assetUtils.ts +++ b/lib/assetUtils.ts @@ -55,4 +55,11 @@ export const ASSETS = { // Content page decorative shapes CONTENT_SHAPE_1: "assets/Content_Shape_1.svg", CONTENT_SHAPE_2: "assets/Content_Shape_2.svg", + + // Alert icons + ICON_ALERT: "assets/Icon_Alert.svg", + ICON_CLOSE: "assets/Icon_Close.svg", + + // Tooltip icons + ICON_POINTER: "assets/Icon_Pointer.svg", } as const; diff --git a/public/assets/Icon_Alert.svg b/public/assets/Icon_Alert.svg new file mode 100644 index 0000000..a69b14d --- /dev/null +++ b/public/assets/Icon_Alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/Icon_Close.svg b/public/assets/Icon_Close.svg new file mode 100644 index 0000000..040915a --- /dev/null +++ b/public/assets/Icon_Close.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/assets/Icon_Pointer.svg b/public/assets/Icon_Pointer.svg new file mode 100644 index 0000000..507656b --- /dev/null +++ b/public/assets/Icon_Pointer.svg @@ -0,0 +1,3 @@ + + + diff --git a/stories/Alert.stories.js b/stories/Alert.stories.js new file mode 100644 index 0000000..1e91c44 --- /dev/null +++ b/stories/Alert.stories.js @@ -0,0 +1,154 @@ +import React, { useState } from "react"; +import Alert from "../app/components/Alert"; + +export default { + title: "Components/Alert", + component: Alert, + argTypes: { + status: { + control: { type: "select" }, + options: ["default", "positive", "warning", "danger"], + }, + type: { + control: { type: "select" }, + options: ["toast", "banner"], + }, + title: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + }, +}; + +const Template = (args) => { + const [isVisible, setIsVisible] = useState(true); + if (!isVisible) return
Alert closed
; + return ( +
+ setIsVisible(false)} /> +
+ ); +}; + +export const Default = Template.bind({}); +Default.args = { + title: "Short alert banner message goes here", + description: "Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.", + status: "default", + type: "toast", +}; + +export const Positive = Template.bind({}); +Positive.args = { + title: "Short alert banner message goes here", + description: "Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.", + status: "positive", + type: "toast", +}; + +export const Warning = Template.bind({}); +Warning.args = { + title: "Short alert banner message goes here", + description: "Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.", + status: "warning", + type: "toast", +}; + +export const Danger = Template.bind({}); +Danger.args = { + title: "Short alert banner message goes here", + description: "Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.", + status: "danger", + type: "toast", +}; + +export const Banner = Template.bind({}); +Banner.args = { + title: "Short alert banner message goes here", + description: "Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.", + status: "default", + type: "banner", +}; + +export const BannerPositive = Template.bind({}); +BannerPositive.args = { + title: "Short alert banner message goes here", + description: "Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.", + status: "positive", + type: "banner", +}; + +export const BannerWarning = Template.bind({}); +BannerWarning.args = { + title: "Short alert banner message goes here", + description: "Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.", + status: "warning", + type: "banner", +}; + +export const BannerDanger = Template.bind({}); +BannerDanger.args = { + title: "Short alert banner message goes here", + description: "Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.", + status: "danger", + type: "banner", +}; + +export const TitleOnly = Template.bind({}); +TitleOnly.args = { + title: "Short alert banner message goes here", + status: "default", + type: "toast", +}; + +export const AllStatuses = () => { + const [visible, setVisible] = useState({ + default: true, + positive: true, + warning: true, + danger: true, + }); + + return ( +
+ {visible.default && ( + setVisible({ ...visible, default: false })} + /> + )} + {visible.positive && ( + setVisible({ ...visible, positive: false })} + /> + )} + {visible.warning && ( + setVisible({ ...visible, warning: false })} + /> + )} + {visible.danger && ( + setVisible({ ...visible, danger: false })} + /> + )} +
+ ); +}; diff --git a/stories/Tooltip.stories.js b/stories/Tooltip.stories.js new file mode 100644 index 0000000..5fa8261 --- /dev/null +++ b/stories/Tooltip.stories.js @@ -0,0 +1,85 @@ +import React from "react"; +import Tooltip from "../app/components/Tooltip"; +import Button from "../app/components/Button"; + +export default { + title: "Components/Tooltip", + component: Tooltip, + argTypes: { + position: { + control: { type: "select" }, + options: ["top", "bottom"], + }, + disabled: { + control: { type: "boolean" }, + }, + text: { + control: { type: "text" }, + }, + }, +}; + +const Template = (args) => ( +
+ + + +
+); + +export const Default = Template.bind({}); +Default.args = { + text: "Tooltip text goes here", + position: "top", + disabled: false, +}; + +export const Top = Template.bind({}); +Top.args = { + text: "Tooltip positioned at top", + position: "top", +}; + +export const Bottom = Template.bind({}); +Bottom.args = { + text: "Tooltip positioned at bottom", + position: "bottom", +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + text: "This tooltip is disabled", + disabled: true, +}; + +export const LongText = Template.bind({}); +LongText.args = { + text: "This is a longer tooltip text that demonstrates how the component handles multiple words and extended content", + position: "top", +}; + +export const WithIcon = () => ( +
+ + + +
+); diff --git a/tests/components/Alert.test.tsx b/tests/components/Alert.test.tsx new file mode 100644 index 0000000..c9f78b3 --- /dev/null +++ b/tests/components/Alert.test.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import Alert from "../../app/components/Alert"; +import { componentTestSuite } from "../utils/componentTestSuite"; + +type AlertProps = React.ComponentProps; + +componentTestSuite({ + component: Alert, + name: "Alert", + props: { + title: "Alert title", + description: "Alert description", + } as AlertProps, + requiredProps: ["title"], + optionalProps: { + description: "Optional description", + status: "positive", + type: "banner", + }, + primaryRole: "alert", + testCases: { + renders: true, + accessibility: true, + keyboardNavigation: false, // Alert is not directly keyboard navigable + disabledState: false, + errorState: false, + }, +}); diff --git a/tests/components/Tooltip.test.tsx b/tests/components/Tooltip.test.tsx new file mode 100644 index 0000000..76b0c29 --- /dev/null +++ b/tests/components/Tooltip.test.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom/vitest"; +import Tooltip from "../../app/components/Tooltip"; +import { componentTestSuite } from "../utils/componentTestSuite"; + +type TooltipProps = React.ComponentProps; + +componentTestSuite({ + component: Tooltip, + name: "Tooltip", + props: { + children: , + text: "Tooltip text", + } as TooltipProps, + requiredProps: ["children", "text"], + optionalProps: { + position: "bottom", + disabled: true, + }, + primaryRole: "button", + testCases: { + renders: true, + accessibility: true, + keyboardNavigation: true, + disabledState: false, // Tooltip disabled state is handled in behavioral tests + errorState: false, + }, +}); + +describe("Tooltip (behavioral tests)", () => { + it("shows tooltip on hover", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Hover me" }); + await user.hover(button); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent("Tooltip text"); + }); + + it("hides tooltip when disabled", () => { + render( + + + , + ); + + const tooltip = screen.queryByRole("tooltip"); + expect(tooltip).not.toBeInTheDocument(); + }); +});