From 43a2437bdfecffd278b875f6645bcfb5d9853bc7 Mon Sep 17 00:00:00 2001 From: ABILASH <114130954+Abilash-19@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:34:02 +0530 Subject: [PATCH 01/17] style: new badge variant --- src/Playground.tsx | 1 + src/components/Badge/Badge.tsx | 11 +++++++++-- src/components/Badge/Badge.types.ts | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Playground.tsx b/src/Playground.tsx index 98db492..6edbdc1 100644 --- a/src/Playground.tsx +++ b/src/Playground.tsx @@ -211,6 +211,7 @@ export default function Playground() { Info Light Dark + Outline diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 6be7bb0..3626692 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -21,6 +21,8 @@ export const Badge = forwardRef( info: "bg-[var(--badge-bg)] text-[var(--badge-text)]", light: "bg-[var(--badge-bg)] text-[var(--badge-text)]", dark: "bg-[var(--badge-bg)] text-[var(--badge-text)]", + outline: + "bg-[var(--badge-bg)] text-[var(--badge-text)] border border-[var(--badge-border)]", }; const sizeStyles = { @@ -40,8 +42,8 @@ export const Badge = forwardRef( themeStyles["--badge-text"] = theme.palette.primary[700]; break; case "secondary": - themeStyles["--badge-bg"] = theme.neutral[100]; - themeStyles["--badge-text"] = theme.neutral[700]; + themeStyles["--badge-bg"] = theme.neutral[200]; + themeStyles["--badge-text"] = theme.neutral[800]; break; case "success": themeStyles["--badge-bg"] = theme.palette.success[100]; @@ -67,6 +69,11 @@ export const Badge = forwardRef( themeStyles["--badge-bg"] = theme.tokens.foreground; themeStyles["--badge-text"] = theme.tokens.background; break; + case "outline": + themeStyles["--badge-bg"] = "transparent"; + themeStyles["--badge-text"] = theme.tokens.foreground; + themeStyles["--badge-border"] = theme.tokens.foreground; + break; } return ( diff --git a/src/components/Badge/Badge.types.ts b/src/components/Badge/Badge.types.ts index 44ef2ba..8803188 100644 --- a/src/components/Badge/Badge.types.ts +++ b/src/components/Badge/Badge.types.ts @@ -7,7 +7,9 @@ export interface BadgeProps { | "warning" | "info" | "light" - | "dark"; + | "dark" + | "outline"; + size?: "small" | "medium" | "large"; children: React.ReactNode; className?: string; From 5b67e026dd129c507532ffc550d74a3a287a914a Mon Sep 17 00:00:00 2001 From: ABILASH <114130954+Abilash-19@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:46:32 +0530 Subject: [PATCH 02/17] feat(skeleton): add Skeleton component with loading states - Skeleton base component with wave/pulse animations - Loading state support for Badge, Input, Typography - Loading state support for Checkbox and Avatar --- src/Playground.tsx | 100 ++++++++++- src/components/Avatar/Avatar.stories.tsx | 6 + src/components/Avatar/Avatar.test.tsx | 5 + src/components/Avatar/Avatar.tsx | 70 ++++---- src/components/Avatar/Avatar.types.ts | 3 +- src/components/Badge/Badge.stories.tsx | 7 + src/components/Badge/Badge.test.tsx | 5 + src/components/Badge/Badge.tsx | 33 +++- src/components/Badge/Badge.types.ts | 1 + src/components/Input/Input.stories.tsx | 157 +++++++++--------- src/components/Input/Input.test.tsx | 5 + src/components/Input/Input.tsx | 79 +++++---- src/components/Skeleton/Skeleton.stories.tsx | 93 +++++++++++ src/components/Skeleton/Skeleton.test.tsx | 54 ++++++ src/components/Skeleton/Skeleton.tsx | 84 ++++++++++ src/components/Skeleton/index.ts | 2 + .../Typography/Typography.stories.tsx | 7 + src/components/Typography/Typography.test.tsx | 5 + src/components/Typography/Typography.tsx | 38 ++--- .../Typography/Typography.types.tsx | 4 + src/components/index.ts | 1 + src/theme/DefaultTheme.ts | 2 +- src/theme/index.css | 54 +++++- 23 files changed, 645 insertions(+), 170 deletions(-) create mode 100644 src/components/Skeleton/Skeleton.stories.tsx create mode 100644 src/components/Skeleton/Skeleton.test.tsx create mode 100644 src/components/Skeleton/Skeleton.tsx create mode 100644 src/components/Skeleton/index.ts diff --git a/src/Playground.tsx b/src/Playground.tsx index 6edbdc1..3ec7c42 100644 --- a/src/Playground.tsx +++ b/src/Playground.tsx @@ -7,6 +7,7 @@ import { Accordion } from "@/components/Accordion"; import { useTheme } from "@/theme"; import { Badge } from "./components/Badge"; import { Avatar } from "@/components/Avatar"; +import { Skeleton } from "./components/Skeleton"; function ThemeToggle() { const { toggleColorMode, theme } = useTheme(); @@ -203,7 +204,9 @@ export default function Playground() { Variants
- Primary + + Primary{" "} + Secondary Success Danger @@ -334,7 +337,7 @@ export default function Playground() { Initials & Fallbacks
- +
- Display 1 + + Display 1 + Display 2 Heading 1 Heading 2 @@ -641,7 +646,7 @@ export default function Playground() { >
- +
+ {/* Skeleton Section */} +
+
+ + Skeleton Placeholders + + + Simplified loading placeholders for various content shapes + +
+ +
+ {/* Shapes */} +
+ + Shapes + +
+
+ +
+ + +
+
+ + +
+
+ + {/* Animations */} +
+ + Animations + +
+
+ + Wave (Default) + + +
+
+ + Pulse + + +
+
+ + None + + +
+
+
+ + {/* Custom Size */} +
+ + Typography Variants + +
+ + + +
+
+
+
+ {/* Modal */} + setIsModalOpen(false)} diff --git a/src/components/Avatar/Avatar.stories.tsx b/src/components/Avatar/Avatar.stories.tsx index e1779d5..6e503e4 100644 --- a/src/components/Avatar/Avatar.stories.tsx +++ b/src/components/Avatar/Avatar.stories.tsx @@ -76,3 +76,9 @@ export const Fallback: Story = { src: "https://invalid-url.com/image.jpg", // Force error }, }; + +export const Loading: Story = { + args: { + isLoading: true, + }, +}; diff --git a/src/components/Avatar/Avatar.test.tsx b/src/components/Avatar/Avatar.test.tsx index a34453d..c797ca4 100644 --- a/src/components/Avatar/Avatar.test.tsx +++ b/src/components/Avatar/Avatar.test.tsx @@ -75,4 +75,9 @@ describe("Avatar", () => { expect(screen.getByText("EU")).toBeInTheDocument(); }); + + it("shows skeleton when loading", () => { + render(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); }); diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index edd37ee..04bc103 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -2,6 +2,7 @@ import { forwardRef, useState } from "react"; import { cn } from "@/utils"; import { useTheme } from "@/theme"; import type { AvatarProps } from "./Avatar.types"; +import { Skeleton } from "../Skeleton"; const Avatar = forwardRef((props, ref) => { const { @@ -15,6 +16,7 @@ const Avatar = forwardRef((props, ref) => { style, badge, onBadgeClick, + isLoading, } = props; const [hasError, setHasError] = useState(false); const { theme } = useTheme(); @@ -55,36 +57,44 @@ const Avatar = forwardRef((props, ref) => { )} style={style} > -
- {src && !fallBack ? ( - {alt setHasError(true)} - aria-label={displayName || "Avatar"} - /> - ) : ( - - {displayName ? getInitials(displayName) : "?"} - - )} -
+ {isLoading ? ( + + ) : ( +
+ {src && !fallBack ? ( + {alt setHasError(true)} + aria-label={displayName || "Avatar"} + /> + ) : ( + + {displayName ? getInitials(displayName) : "?"} + + )} +
+ )} {badge && (
void; - // showTooltip?: boolean; - // tooltipContent?: string; + isLoading?: boolean; } diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx index b077806..cb662b0 100644 --- a/src/components/Badge/Badge.stories.tsx +++ b/src/components/Badge/Badge.stories.tsx @@ -130,3 +130,10 @@ export const AllVariants: Story = {
), }; + +export const Loading: Story = { + args: { + children: "Loading", + isLoading: true, + }, +}; diff --git a/src/components/Badge/Badge.test.tsx b/src/components/Badge/Badge.test.tsx index eee711b..c094523 100644 --- a/src/components/Badge/Badge.test.tsx +++ b/src/components/Badge/Badge.test.tsx @@ -55,4 +55,9 @@ describe("Badge", () => { const { container } = render(Style); expect(container.firstChild).toHaveStyle({ color: "rgb(255, 0, 0)" }); }); + + it("shows skeleton when loading", () => { + render(Loading); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); }); diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 3626692..00d3c7f 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -2,10 +2,18 @@ import { forwardRef } from "react"; import { cn } from "@/utils"; import { useTheme } from "@/theme"; import type { BadgeProps } from "./Badge.types"; +import { Skeleton } from "../Skeleton"; export const Badge = forwardRef( ( - { variant = "primary", size = "medium", children, className, style }, + { + variant = "primary", + size = "medium", + children, + className, + style, + isLoading, + }, ref, ) => { const { theme } = useTheme(); @@ -36,6 +44,15 @@ export const Badge = forwardRef( "--badge-text": theme.palette.primary[700], }; + const SKELETON_SIZE_MAP: Record< + NonNullable, + { height: string; width: string } + > = { + small: { height: "20", width: "48" }, + medium: { height: "24", width: "56" }, + large: { height: "28", width: "64" }, + }; + switch (variant) { case "primary": themeStyles["--badge-bg"] = theme.palette.primary[100]; @@ -76,6 +93,20 @@ export const Badge = forwardRef( break; } + if (isLoading) { + const { height, width } = SKELETON_SIZE_MAP[size]; + + return ( + + ); + } + return ( = { - title: 'Components/Input', - component: Input, - parameters: { - layout: 'centered', + title: "Components/Input", + component: Input, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + inputSize: { + control: "select", + options: ["sm", "md", "lg"], }, - tags: ['autodocs'], - argTypes: { - inputSize: { - control: 'select', - options: ['sm', 'md', 'lg'], - }, - disabled: { - control: 'boolean', - }, + disabled: { + control: "boolean", }, + }, }; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - placeholder: 'Enter text...', - }, + args: { + placeholder: "Enter text...", + }, }; export const WithLabel: Story = { - args: { - label: 'Username', - placeholder: 'Enter your username', - }, + args: { + label: "Username", + placeholder: "Enter your username", + }, }; export const WithError: Story = { - args: { - label: 'Email', - placeholder: 'your.email@example.com', - error: 'Please enter a valid email address', - }, + args: { + label: "Email", + placeholder: "your.email@example.com", + error: "Please enter a valid email address", + }, }; export const WithHelperText: Story = { - args: { - label: 'Full Name', - placeholder: 'John Doe', - helperText: 'Enter your first and last name', - }, + args: { + label: "Full Name", + placeholder: "John Doe", + helperText: "Enter your first and last name", + }, }; export const Small: Story = { - args: { - label: 'Small Input', - placeholder: 'Small size', - inputSize: 'sm', - }, + args: { + label: "Small Input", + placeholder: "Small size", + inputSize: "sm", + }, }; export const Medium: Story = { - args: { - label: 'Medium Input', - placeholder: 'Medium size', - inputSize: 'md', - }, + args: { + label: "Medium Input", + placeholder: "Medium size", + inputSize: "md", + }, }; export const Large: Story = { - args: { - label: 'Large Input', - placeholder: 'Large size', - inputSize: 'lg', - }, + args: { + label: "Large Input", + placeholder: "Large size", + inputSize: "lg", + }, }; export const Disabled: Story = { - args: { - label: 'Disabled Input', - placeholder: 'Cannot edit', - disabled: true, - }, + args: { + label: "Disabled Input", + placeholder: "Cannot edit", + disabled: true, + }, }; export const AllSizes: Story = { - render: () => ( -
- - - -
- ), + render: () => ( +
+ + + +
+ ), }; export const FormExample: Story = { - render: () => ( -
- - - -
- ), + render: () => ( +
+ + + +
+ ), +}; + +export const Loading: Story = { + args: { + label: "Loading Input", + isLoading: true, + }, }; diff --git a/src/components/Input/Input.test.tsx b/src/components/Input/Input.test.tsx index 8745465..1463800 100644 --- a/src/components/Input/Input.test.tsx +++ b/src/components/Input/Input.test.tsx @@ -96,4 +96,9 @@ describe("Input", () => { expect(label).toHaveAttribute("for", "email-input"); expect(input).toHaveAttribute("id", "email-input"); }); + + it("shows skeleton when loading", () => { + render(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); }); diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index e9c42a8..2cc1df3 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -1,29 +1,33 @@ import { forwardRef, useId, type InputHTMLAttributes, useMemo } from "react"; import { cn } from "@/utils"; import { useTheme } from "@/theme"; +import { Skeleton } from "../Skeleton"; export interface InputProps extends InputHTMLAttributes { + id?: string; label?: string; error?: string; helperText?: string; inputSize?: "sm" | "md" | "lg"; leftIcon?: React.ReactNode; rightIcon?: React.ReactNode; + isLoading?: boolean; } const Input = forwardRef( ( { + id, className, label, error, helperText, inputSize = "md", - id, disabled, required, leftIcon, rightIcon, + isLoading, ...props }, ref, @@ -70,40 +74,51 @@ const Input = forwardRef( )} -
- {leftIcon && ( - {leftIcon} - )} - - + ) : ( +
+ > + {leftIcon && ( + {leftIcon} + )} + + - {rightIcon && ( - {rightIcon} - )} -
+ {rightIcon && ( + + {rightIcon} + + )} +
+ )} {error && (
+ +
+ + +
+
+ ), +}; diff --git a/src/components/Skeleton/Skeleton.test.tsx b/src/components/Skeleton/Skeleton.test.tsx new file mode 100644 index 0000000..bd6e7ae --- /dev/null +++ b/src/components/Skeleton/Skeleton.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { render as rtlRender, screen } from "@testing-library/react"; +import { Skeleton } from "./Skeleton"; +import { ThemeProvider } from "../../theme"; + +const render = (ui: React.ReactElement) => { + return rtlRender({ui}); +}; + +describe("Skeleton", () => { + it("renders correctly", () => { + render(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("applies variant styles correctly", () => { + const span = screen.getByRole("status"); + expect(span.style.borderRadius).toBe("50%"); + }); + + it("applies width and height correctly", () => { + render(); + const span = screen.getByRole("status"); + expect(span.style.width).toBe("100px"); + expect(span.style.height).toBe("50px"); + }); + + it("applies animation class correctly", () => { + const { rerender } = render(); + let span = screen.getByRole("status"); + expect(span).toHaveClass("skeleton-pulse"); + + rerender( + + + , + ); + span = screen.getByRole("status"); + expect(span).toHaveClass("skeleton-wave"); + }); + + it("applies custom className", () => { + render(); + expect(screen.getByRole("status")).toHaveClass("custom-class"); + }); + + it("has accessible roles and labels", () => { + render(); + const span = screen.getByRole("status"); + expect(span).toHaveAttribute("aria-label", "Loading…"); + expect(span).toHaveAttribute("aria-busy", "true"); + }); +}); diff --git a/src/components/Skeleton/Skeleton.tsx b/src/components/Skeleton/Skeleton.tsx new file mode 100644 index 0000000..5b6f280 --- /dev/null +++ b/src/components/Skeleton/Skeleton.tsx @@ -0,0 +1,84 @@ +import { useTheme } from "@/theme"; +import type { CSSProperties } from "react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type SkeletonVariant = "rectangular" | "circular" | "text" | "rounded"; +type SkeletonAnimation = "pulse" | "wave" | "none"; + +export interface SkeletonProps { + variant?: SkeletonVariant; + animation?: SkeletonAnimation; + width?: number | string; + height?: number | string; + className?: string; + style?: CSSProperties; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function px(v: number | string | undefined): string | undefined { + if (v === undefined) return undefined; + return typeof v === "number" ? `${v}px` : v; +} + +function variantStyles(variant: SkeletonVariant): CSSProperties { + switch (variant) { + case "circular": + return { borderRadius: "50%" }; + case "rounded": + return { borderRadius: "12px" }; + case "text": + return { + borderRadius: "4px", + transform: "scale(1, 0.85)", + transformOrigin: "0 55%", + }; + default: + return { borderRadius: "4px" }; + } +} + +export function Skeleton({ + variant = "rectangular", + animation = "wave", + width, + height, + className = "", + style, +}: SkeletonProps) { + const { theme } = useTheme(); + const resolvedWidth = px(width); + const resolvedHeight = + px(height) ?? + (variant === "text" ? "1em" : variant === "circular" ? px(width) : "100%"); + + const animationClass = + animation === "pulse" + ? "skeleton-pulse" + : animation === "wave" + ? "skeleton-wave" + : ""; + + return ( + + ); +} + +export default Skeleton; diff --git a/src/components/Skeleton/index.ts b/src/components/Skeleton/index.ts new file mode 100644 index 0000000..de1f29c --- /dev/null +++ b/src/components/Skeleton/index.ts @@ -0,0 +1,2 @@ +export * from "./Skeleton"; +export { default } from "./Skeleton"; diff --git a/src/components/Typography/Typography.stories.tsx b/src/components/Typography/Typography.stories.tsx index d9cec9c..bb67a54 100644 --- a/src/components/Typography/Typography.stories.tsx +++ b/src/components/Typography/Typography.stories.tsx @@ -208,3 +208,10 @@ export const RealisticExample: Story = { ), }; + +export const Loading: Story = { + args: { + isLoading: true, + children: "This content is loading...", + }, +}; diff --git a/src/components/Typography/Typography.test.tsx b/src/components/Typography/Typography.test.tsx index 0272958..309d831 100644 --- a/src/components/Typography/Typography.test.tsx +++ b/src/components/Typography/Typography.test.tsx @@ -308,4 +308,9 @@ describe("Typography", () => { expect(heading).toHaveAttribute("id", "section-heading"); }); }); + + it("shows skeleton when loading", () => { + render(Loading); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); }); diff --git a/src/components/Typography/Typography.tsx b/src/components/Typography/Typography.tsx index bc97dba..e915776 100644 --- a/src/components/Typography/Typography.tsx +++ b/src/components/Typography/Typography.tsx @@ -7,17 +7,8 @@ import type { } from "./Typography.types"; import { VARIANT_ELEMENT_MAP, VARIANT_WEIGHT_MAP } from "./Typography.types"; import { useTheme } from "@/theme"; +import { Skeleton } from "../Skeleton"; -/** - * Typography component for consistent text styling across the application - * - * @example - * ```tsx - * Main Heading - * Body text - * Custom element - * ``` - */ export const Typography: React.FC = ({ variant = "body1", as, @@ -27,29 +18,36 @@ export const Typography: React.FC = ({ children, id, onClick, + isLoading, ...rest }) => { const { theme } = useTheme(); - // Determine the HTML element to render + + if (isLoading) { + return ( + + ); + } const Element = as || VARIANT_ELEMENT_MAP[variant] || "p"; - // Determine font weight const fontWeight = weight || VARIANT_WEIGHT_MAP[variant]; const color = theme.tokens.foreground; - // Build class names const classes = cn( - // Base styles "typography", - - // Variant styles getVariantClasses(variant), - - // Font weight getWeightClasses(fontWeight), - - // Custom classes className, ); diff --git a/src/components/Typography/Typography.types.tsx b/src/components/Typography/Typography.types.tsx index 853d647..d61b91d 100644 --- a/src/components/Typography/Typography.types.tsx +++ b/src/components/Typography/Typography.types.tsx @@ -75,6 +75,10 @@ export interface TypographyProps { * onClick handler */ htmlFor?: string; + /** + * Whether to show a skeleton loading state + */ + isLoading?: boolean; } /** diff --git a/src/components/index.ts b/src/components/index.ts index d55f9d1..a67274e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export * from "./Modal"; export * from "./Typography"; export * from "./Badge"; export * from "./Avatar"; +export * from "./Skeleton"; // ── Theme ── export { ThemeProvider, useTheme, defaultTheme, darkTheme } from "../theme"; diff --git a/src/theme/DefaultTheme.ts b/src/theme/DefaultTheme.ts index c12130d..b7c9b72 100644 --- a/src/theme/DefaultTheme.ts +++ b/src/theme/DefaultTheme.ts @@ -70,7 +70,7 @@ const palette = { } satisfies ThemeConfig["palette"]; const neutral: ThemeConfig["neutral"] = { - 50: "#f9fafb", + 50: "#fbfaf9ff", 100: "#f3f4f6", 200: "#e5e7eb", 300: "#d1d5db", diff --git a/src/theme/index.css b/src/theme/index.css index 04f7ae1..cf50957 100644 --- a/src/theme/index.css +++ b/src/theme/index.css @@ -4,11 +4,55 @@ @tailwind components; @tailwind utilities; -:root { - --radius: 0.5rem; +@keyframes skeleton-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + +@keyframes skeleton-wave { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} + +.skeleton-root { + display: block; + background-color: var(--color-border-subtle); + border-radius: var(--radius-sm, 4px); + overflow: hidden; + position: relative; +} + +.skeleton-pulse { + animation: skeleton-pulse 2s ease-in-out infinite; +} + +.skeleton-wave { + position: relative; } -.dark { - --color-background: #020617; - --color-foreground: #f8fafc; +.skeleton-wave::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent 0, + var(--color-border) 50%, + transparent 100% + ); + animation: skeleton-wave 1.6s linear infinite; } From ba6a58675209a0bee4e375f48e132968170b3229 Mon Sep 17 00:00:00 2001 From: ABILASH <114130954+Abilash-19@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:51:36 +0530 Subject: [PATCH 03/17] test: storybook test case --- src/components/Skeleton/Skeleton.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Skeleton/Skeleton.test.tsx b/src/components/Skeleton/Skeleton.test.tsx index bd6e7ae..ef908fc 100644 --- a/src/components/Skeleton/Skeleton.test.tsx +++ b/src/components/Skeleton/Skeleton.test.tsx @@ -15,15 +15,15 @@ describe("Skeleton", () => { }); it("applies variant styles correctly", () => { + render(); const span = screen.getByRole("status"); - expect(span.style.borderRadius).toBe("50%"); + expect(span).toHaveStyle({ borderRadius: "50%" }); }); it("applies width and height correctly", () => { render(); const span = screen.getByRole("status"); - expect(span.style.width).toBe("100px"); - expect(span.style.height).toBe("50px"); + expect(span).toHaveStyle({ width: "100px", height: "50px" }); }); it("applies animation class correctly", () => { From 385954676459423836ddad75182f0b08cf077761 Mon Sep 17 00:00:00 2001 From: ABILASH <114130954+Abilash-19@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:25:17 +0530 Subject: [PATCH 04/17] feat(checkbox): implement reusable Checkbox component with indeterminate and loading states --- src/Playground.tsx | 172 +++++++++++- src/assets/Icons/CheckIcon.tsx | 20 ++ src/assets/Icons/InterminateIcon.tsx | 20 ++ src/components/Badge/Badge.tsx | 8 +- src/components/Checkbox/Checkbox.constants.ts | 33 +++ src/components/Checkbox/Checkbox.stories.tsx | 260 ++++++++++++++++++ src/components/Checkbox/Checkbox.test.tsx | 64 +++++ src/components/Checkbox/Checkbox.tsx | 166 +++++++++++ src/components/Checkbox/Checkbox.types.ts | 35 +++ src/components/Checkbox/index.ts | 3 + src/components/index.ts | 1 + 11 files changed, 777 insertions(+), 5 deletions(-) create mode 100644 src/assets/Icons/CheckIcon.tsx create mode 100644 src/assets/Icons/InterminateIcon.tsx create mode 100644 src/components/Checkbox/Checkbox.constants.ts create mode 100644 src/components/Checkbox/Checkbox.stories.tsx create mode 100644 src/components/Checkbox/Checkbox.test.tsx create mode 100644 src/components/Checkbox/Checkbox.tsx create mode 100644 src/components/Checkbox/Checkbox.types.ts create mode 100644 src/components/Checkbox/index.ts diff --git a/src/Playground.tsx b/src/Playground.tsx index 3ec7c42..fa415da 100644 --- a/src/Playground.tsx +++ b/src/Playground.tsx @@ -7,7 +7,8 @@ import { Accordion } from "@/components/Accordion"; import { useTheme } from "@/theme"; import { Badge } from "./components/Badge"; import { Avatar } from "@/components/Avatar"; -import { Skeleton } from "./components/Skeleton"; +import { Skeleton } from "@/components/Skeleton"; +import Checkbox from "@/components/Checkbox"; function ThemeToggle() { const { toggleColorMode, theme } = useTheme(); @@ -48,6 +49,29 @@ export default function Playground() { "--typography-color": theme.tokens.foregroundMuted, } as React.CSSProperties; + const [checkedItems, setCheckedItems] = useState({ + item1: false, + item2: false, + item3: false, + }); + + const allChecked = Object.values(checkedItems).every(Boolean); + const isIndeterminate = + Object.values(checkedItems).some(Boolean) && !allChecked; + + const handleToggleAll = (checked: boolean) => { + setCheckedItems({ + item1: checked, + item2: checked, + item3: checked, + }); + }; + + const handleToggleItem = + (item: keyof typeof checkedItems) => (checked: boolean) => { + setCheckedItems((prev) => ({ ...prev, [item]: checked })); + }; + return (
+ {/* Checkbox Section */} +
+
+ + Checkboxes + + + Selection controls with support for labels, descriptions, and + interactive indeterminate states + +
+ +
+ {/* Interactive Playground */} +
+
+ +
+
+ + + +
+
+ + {/* Sizes & States */} +
+
+
+ + Available Sizes + +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + Visual Feedback + +
+ + +
+
+
+ +
+ + Native States + +
+ + + + +
+
+ + Combine labels, descriptions, and icons to create clear and + actionable interfaces. + +
+
+
+
+
{/* Modal */} diff --git a/src/assets/Icons/CheckIcon.tsx b/src/assets/Icons/CheckIcon.tsx new file mode 100644 index 0000000..4c7c107 --- /dev/null +++ b/src/assets/Icons/CheckIcon.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/utils"; + +export const CheckIcon = ({ size }: { size: string }) => { + return ( + + + + ); +}; diff --git a/src/assets/Icons/InterminateIcon.tsx b/src/assets/Icons/InterminateIcon.tsx new file mode 100644 index 0000000..90baad9 --- /dev/null +++ b/src/assets/Icons/InterminateIcon.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/utils/cn"; + +export const InterminateIcon = ({ size }: { size: string }) => { + return ( + + + + ); +}; diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 00d3c7f..0166157 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -46,11 +46,11 @@ export const Badge = forwardRef( const SKELETON_SIZE_MAP: Record< NonNullable, - { height: string; width: string } + { height: number; width: number } > = { - small: { height: "20", width: "48" }, - medium: { height: "24", width: "56" }, - large: { height: "28", width: "64" }, + small: { height: 20, width: 48 }, + medium: { height: 24, width: 56 }, + large: { height: 28, width: 64 }, }; switch (variant) { diff --git a/src/components/Checkbox/Checkbox.constants.ts b/src/components/Checkbox/Checkbox.constants.ts new file mode 100644 index 0000000..184a641 --- /dev/null +++ b/src/components/Checkbox/Checkbox.constants.ts @@ -0,0 +1,33 @@ +import type { CheckboxProps } from "./Checkbox.types"; + +export const labelSizeClasses = { + small: "text-[13px] font-medium leading-[1.4]", + medium: "text-[14px] font-medium leading-[1.4]", + large: "text-[16px] font-medium leading-[1.4]", +}; +export const descriptionSizeClasses = { + small: "text-[12px] leading-[1.4]", + medium: "text-[13px] leading-[1.4]", + large: "text-[14px] leading-[1.4]", +}; + +export const sizeClasses = { + small: "h-4 w-4", + medium: "h-5 w-5", + large: "h-6 w-6", +}; + +export const iconSizeClasses = { + small: "w-2 h-2", + medium: "w-3 h-3", + large: "w-3.5 h-3.5", +}; + +export const SKELETON_SIZE_MAP: Record< + NonNullable, + { height: string; width: string } +> = { + small: { height: "16px", width: "16px" }, + medium: { height: "20px", width: "20px" }, + large: { height: "24px", width: "24px" }, +}; diff --git a/src/components/Checkbox/Checkbox.stories.tsx b/src/components/Checkbox/Checkbox.stories.tsx new file mode 100644 index 0000000..295fb85 --- /dev/null +++ b/src/components/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,260 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import Checkbox from "./Checkbox"; +import { useState } from "react"; + +const meta: Meta = { + title: "Components/Checkbox", + component: Checkbox, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + size: { + control: "select", + options: ["small", "medium", "large"], + }, + disabled: { control: "boolean" }, + error: { control: "boolean" }, + loading: { control: "boolean" }, + indeterminate: { control: "boolean" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Accept terms and conditions", + id: "terms", + }, +}; + +export const WithDescription: Story = { + args: { + label: "Notifications", + description: "Receive updates about new features and announcements.", + id: "notifications", + }, +}; + +export const Playground: Story = { + render: () => { + const [checkedItems, setCheckedItems] = useState({ + item1: false, + item2: false, + item3: false, + }); + + const allChecked = Object.values(checkedItems).every(Boolean); + const isIndeterminate = + Object.values(checkedItems).some(Boolean) && !allChecked; + + const handleToggleAll = (checked: boolean) => { + setCheckedItems({ + item1: checked, + item2: checked, + item3: checked, + }); + }; + + const handleToggleItem = + (item: keyof typeof checkedItems) => (checked: boolean) => { + setCheckedItems((prev) => ({ ...prev, [item]: checked })); + }; + + return ( +
+
+

+ Email Preferences +

+

+ Manage how you receive updates and alerts. +

+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+ ); + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+
+

Standard Sizes

+

+ Consistency across different scales. +

+
+
+
+ + + 14px font + +
+
+ + + 14px font + +
+
+ + + 16px font + +
+
+
+ +
+
+

+ Interaction States +

+

+ Visual feedback for every user action. +

+
+
+
+

+ Basic +

+
+ + + +
+
+
+

+ Disabled +

+
+ + + +
+
+
+

+ Feedback +

+
+ + +
+
+
+
+ +
+
+

Content Layouts

+

+ Combining labels with helper text and descriptions. +

+
+
+
+ +
+
+ +
+
+
+
+ ), +}; + +export const Loading: Story = { + args: { + loading: true, + label: "Loading state", + description: "This will show a skeleton loader.", + }, +}; diff --git a/src/components/Checkbox/Checkbox.test.tsx b/src/components/Checkbox/Checkbox.test.tsx new file mode 100644 index 0000000..a1e79b7 --- /dev/null +++ b/src/components/Checkbox/Checkbox.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import Checkbox from "./Checkbox"; +import { ThemeProvider } from "@/theme"; + +const renderWithTheme = (ui: React.ReactElement) => { + return render({ui}); +}; + +describe("Checkbox", () => { + it("renders with a label", () => { + renderWithTheme(); + expect(screen.getByLabelText("Test Label")).toBeInTheDocument(); + }); + + it("handles checked state change", () => { + const onChange = vi.fn(); + renderWithTheme(); + const input = screen.getByRole("checkbox"); + + fireEvent.click(input); + expect(onChange).toHaveBeenCalledWith(true); + + fireEvent.click(input); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it("renders in indeterminate state", () => { + renderWithTheme(); + const input = screen.getByRole("checkbox") as HTMLInputElement; + expect(input.indeterminate).toBe(true); + }); + + it("is disabled when disabled prop is true", () => { + renderWithTheme(); + const input = screen.getByRole("checkbox"); + expect(input).toBeDisabled(); + }); + + it("shows error state", () => { + renderWithTheme( + , + ); + expect(screen.getByText("Error message")).toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toHaveAttribute( + "aria-invalid", + "true", + ); + }); + + it("renders loading state", () => { + const { container } = renderWithTheme(); + expect(container.querySelector(".skeleton-root")).toBeInTheDocument(); + }); + + it("associates description via aria-describedby", () => { + renderWithTheme( + , + ); + const input = screen.getByRole("checkbox"); + const description = screen.getByText("This is a description"); + expect(input).toHaveAttribute("aria-describedby", description.id); + }); +}); diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..2233595 --- /dev/null +++ b/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,166 @@ +import { forwardRef, useEffect, useRef, useImperativeHandle } from "react"; +import type { CheckboxProps } from "./Checkbox.types"; +import { useTheme } from "@/theme"; +import Skeleton from "../Skeleton"; +import { cn } from "@/utils/cn"; +import { + descriptionSizeClasses, + iconSizeClasses, + labelSizeClasses, + sizeClasses, + SKELETON_SIZE_MAP, +} from "./Checkbox.constants"; +import { CheckIcon } from "@/assets/Icons/CheckIcon"; +import { InterminateIcon } from "@/assets/Icons/InterminateIcon"; + +const Checkbox = forwardRef( + ( + { + checked, + defaultChecked, + disabled, + required, + loading, + size = "medium", + className = "", + style, + onChange, + error, + description, + label, + id, + indeterminate, + ...props + }, + ref, + ) => { + const { theme } = useTheme(); + const internalRef = useRef(null); + + useEffect(() => { + if (internalRef.current) { + internalRef.current.indeterminate = indeterminate ?? false; + } + }, [indeterminate]); + + useImperativeHandle(ref, () => internalRef.current!); + + if (loading) { + return ( +
+ +
+ + {description && ( + + )} +
+
+ ); + } + + const themeStyles = { + "--primary": theme.palette.primary[500], + "--error": theme.palette.error[500], + "--ring": theme.tokens.ring, + "--border-color": error ? theme.palette.error[500] : theme.tokens.border, + "--bg-color": theme.tokens.surface, + "--text-color": theme.tokens.foreground, + "--text-secondary": theme.tokens.foregroundMuted, + } as React.CSSProperties; + + const describedBy = [description && `${id}-description`] + .filter(Boolean) + .join(" "); + + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e.target.checked); + }; + + return ( + + ); + }, +); + +Checkbox.displayName = "Checkbox"; + +export default Checkbox; diff --git a/src/components/Checkbox/Checkbox.types.ts b/src/components/Checkbox/Checkbox.types.ts new file mode 100644 index 0000000..f014f35 --- /dev/null +++ b/src/components/Checkbox/Checkbox.types.ts @@ -0,0 +1,35 @@ +import type { InputHTMLAttributes, ReactNode } from "react"; + +export interface CheckboxProps extends Omit< + InputHTMLAttributes, + "size" | "onChange" +> { + /** + * Label for the checkbox + */ + label?: ReactNode; + /** + * Optional description text below the label + */ + description?: ReactNode; + /** + * If true, the checkbox will be in an error state + */ + error?: boolean; + /** + * Whether the checkbox is in a loading state + */ + loading?: boolean; + /** + * Circular progress size + */ + size?: "small" | "medium" | "large"; + /** + * If true, the checkbox will be indeterminate + */ + indeterminate?: boolean; + /** + * Callback when the checked state changes + */ + onChange?: (checked: boolean) => void; +} diff --git a/src/components/Checkbox/index.ts b/src/components/Checkbox/index.ts new file mode 100644 index 0000000..dc638ad --- /dev/null +++ b/src/components/Checkbox/index.ts @@ -0,0 +1,3 @@ +export { default } from "./Checkbox"; +export * from "./Checkbox.types"; +export * from "./Checkbox.constants"; diff --git a/src/components/index.ts b/src/components/index.ts index a67274e..66ea26d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,7 @@ export * from "./Typography"; export * from "./Badge"; export * from "./Avatar"; export * from "./Skeleton"; +export * from "./Checkbox"; // ── Theme ── export { ThemeProvider, useTheme, defaultTheme, darkTheme } from "../theme"; From b428e2a30dc82e95f52968b2b0f98af1c653fa75 Mon Sep 17 00:00:00 2001 From: ABILASH <114130954+Abilash-19@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:23:42 +0530 Subject: [PATCH 05/17] feat: introduce ScrollArea component with reveal animations --- src/Playground.tsx | 209 ++++++++++++++++- .../ScrollArea/ScrollArea.stories.tsx | 115 ++++++++++ src/components/ScrollArea/ScrollArea.tsx | 69 ++++++ src/components/ScrollArea/ScrollArea.types.ts | 66 ++++++ src/components/ScrollArea/index.ts | 2 + src/components/index.ts | 1 + src/hooks/useReveal.ts | 46 ++++ src/theme/DefaultTheme.ts | 20 +- src/theme/index.css | 214 ++++++++++++++++++ 9 files changed, 731 insertions(+), 11 deletions(-) create mode 100644 src/components/ScrollArea/ScrollArea.stories.tsx create mode 100644 src/components/ScrollArea/ScrollArea.tsx create mode 100644 src/components/ScrollArea/ScrollArea.types.ts create mode 100644 src/components/ScrollArea/index.ts create mode 100644 src/hooks/useReveal.ts diff --git a/src/Playground.tsx b/src/Playground.tsx index fa415da..5391d06 100644 --- a/src/Playground.tsx +++ b/src/Playground.tsx @@ -9,7 +9,7 @@ import { Badge } from "./components/Badge"; import { Avatar } from "@/components/Avatar"; import { Skeleton } from "@/components/Skeleton"; import Checkbox from "@/components/Checkbox"; - +import { ScrollArea, ScrollElement } from "@/components/ScrollArea"; function ThemeToggle() { const { toggleColorMode, theme } = useTheme(); return ( @@ -49,6 +49,8 @@ export default function Playground() { "--typography-color": theme.tokens.foregroundMuted, } as React.CSSProperties; + const [isSimulatingData, setIsSimulatingData] = useState(true); + const [checkedItems, setCheckedItems] = useState({ item1: false, item2: false, @@ -792,6 +794,102 @@ export default function Playground() {
+ + {/* Real Data Simulation */} +
+
+
+ + Real Data simulation + + + Toggle to see how skeletons transition to real content + +
+ +
+ +
+ {[1, 2, 3].map((i) => ( +
+
+ {isSimulatingData ? ( + + ) : ( + + )} +
+ {isSimulatingData ? ( + <> + + + + ) : ( + <> + + { + ["Alice Freeman", "Bob Smith", "Charlie Davis"][ + i - 1 + ] + } + + + { + [ + "Senior Designer", + "Software Engineer", + "Product Manager", + ][i - 1] + } + + + )} +
+
+
+ {isSimulatingData ? ( +
+ + + +
+ ) : ( + + { + [ + "Creative designer with a passion for building beautiful UI components.", + "Backend developer focused on scalable architecture and performance.", + "Strategic thinker driving product vision and execution.", + ][i - 1] + } + + )} +
+
+ ))} +
+
{/* Checkbox Section */}
+ {/* ScrollArea Section */} +
+
+ + Scroll Area + + + Customizable scrolling container with support for custom scrollbars + and visibility controls + +
+ +
+ {/* Vertical Scroll */} +
+ + Vertical Scrolling + +
+ +
+ {[...Array(12)].map((_, i) => ( + + + Scroll Item {i + 1} + + + This is a sample item inside the vertical scroll area. + It demonstrates how the container handles overflowing + vertical content. + + + ))} +
+
+
+
+ + {/* Horizontal Scroll */} +
+ + Horizontal Scrolling + +
+ +
+ {[...Array(8)].map((_, i) => ( + +
+ #{i + 1} +
+ + Horizontal Item {i + 1} + + + This card scrolls horizontally. + +
+ ))} +
+
+
+
+
+
+ {/* Modal */} = { + title: "Components/ScrollArea", + component: ScrollArea, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + enableAnimation: { + control: "boolean", + description: "Toggles reveal animations globally for all nested Items.", + }, + ScrollAnimation: { + control: "select", + options: ["up", "down", "left", "right", "fade", "scale", "blur"], + description: "Default animation applied to all children.", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const DefaultVertical: Story = { + args: { + className: "h-[300px] w-[400px] border p-4 rounded-xl", + }, + render: (args) => ( + +
+ {[...Array(10)].map((_, i) => ( + + Item {i + 1} + + ))} +
+
+ ), +}; + +export const HorizontalScroll: Story = { + args: { + className: "w-[600px] border p-4 rounded-xl", + }, + render: (args) => ( + +
+ {[...Array(8)].map((_, i) => ( + + Horizontal {i + 1} + + ))} +
+
+ ), +}; + +export const MixedAnimations: Story = { + args: { + className: "h-[400px] w-[500px] border p-4 rounded-xl", + }, + render: (args) => ( + +
+ + I will slide UP when scrolled into view! + + + I will slide DOWN. + + + I will slide LEFT. + + + I will slide RIGHT. + + + I will BLUR in elegantly. + +
+
+ ), +}; + +export const AnimationsDisabled: Story = { + args: { + className: "h-[300px] w-[400px] border p-4 rounded-xl", + enableAnimation: false, + }, + render: (args) => ( + +
+ + Visible instantly + + + No animations here either. + +
+
+ ), +}; diff --git a/src/components/ScrollArea/ScrollArea.tsx b/src/components/ScrollArea/ScrollArea.tsx new file mode 100644 index 0000000..968621f --- /dev/null +++ b/src/components/ScrollArea/ScrollArea.tsx @@ -0,0 +1,69 @@ +import { useTheme } from "@/theme"; +import type { ScrollAreaProps, ScrollAreaItemProps } from "./ScrollArea.types"; +import { useReveal } from "@/hooks/useReveal"; + +const ScrollAreaComponent = ({ + id, + ref, + children, + className = "", + style, + onScroll, +}: ScrollAreaProps) => { + const { theme } = useTheme(); + + const themeStyles = { + "--scroll-thumb": theme.tokens.foregroundMuted, + "--scroll-thumb-hover": theme.tokens.foreground, + "--scroll-thumb-active": theme.palette.primary[500], + } as React.CSSProperties; + + return ( +
+ {children} +
+ ); +}; + +export const ScrollAreaItem = ({ + children, + className = "", + style, + triggerOnce, + revealType = "scale", +}: ScrollAreaItemProps) => { + const { elementRef, isVisible } = useReveal({ triggerOnce }); + + const ANIMATION_CLASS_MAP: Record< + NonNullable, + string + > = { + up: "reveal-up", + down: "reveal-down", + left: "reveal-left", + right: "reveal-right", + fade: "fade-in", + scale: "reveal-scale", + blur: "reveal-blur", + }; + + return ( +
+ {children} +
+ ); +}; +console.log(Object.assign(ScrollAreaComponent, { item: ScrollAreaItem })); + +export { ScrollAreaComponent as ScrollArea }; +export { ScrollAreaItem as ScrollElement }; diff --git a/src/components/ScrollArea/ScrollArea.types.ts b/src/components/ScrollArea/ScrollArea.types.ts new file mode 100644 index 0000000..484f407 --- /dev/null +++ b/src/components/ScrollArea/ScrollArea.types.ts @@ -0,0 +1,66 @@ +export interface ScrollAreaProps { + /** Unique identifier for the scrollable container. */ + id?: string; + + /** Ref forwarded to the underlying scroll element. */ + ref?: React.RefObject; + + /** Content rendered inside the scrollable container. */ + children: React.ReactNode; + + /** Additional class names applied to the container. */ + className?: string; + + /** Inline styles applied to the container. */ + style?: React.CSSProperties; + + /** Callback fired when the container is scrolled. */ + onScroll?: (event: React.UIEvent) => void; + + /** + * Controls the reveal animation applied to child items on scroll. + * - `"up"` / `"down"` / `"left"` / `"right"` — slides in from a direction + * - `"fade"` — fades in + * - `"scale"` — scales up from center + * - `"blur"` — unblurs into view + * @defaultValue `"fade"` + */ + ScrollAnimation?: + | "up" + | "down" + | "left" + | "right" + | "fade" + | "scale" + | "blur"; +} + +export interface ScrollAreaItemProps { + /** Content rendered inside the scroll item. */ + children: React.ReactNode; + + /** Additional class names applied to the item. */ + className?: string; + + /** Inline styles applied to the item. */ + style?: React.CSSProperties; + + /** + * When `true`, the reveal animation only plays the first time the item + * enters the viewport. When `false`, it replays on every re-entry. + * @defaultValue `true` + */ + triggerOnce?: boolean; + + /** + * When `true`, the reveal animation only plays the first time the item + * enters the viewport. When `false`, it replays on every re-entry. + * @defaultValue `true` + */ + threshold?: number; + /** + * Overrides the parent `ScrollArea` animation for this specific item. + * Inherits from `ScrollAreaProps.ScrollAnimation` when omitted. + */ + revealType?: NonNullable; +} diff --git a/src/components/ScrollArea/index.ts b/src/components/ScrollArea/index.ts new file mode 100644 index 0000000..755a2e0 --- /dev/null +++ b/src/components/ScrollArea/index.ts @@ -0,0 +1,2 @@ +export * from "./ScrollArea"; +export * from "./ScrollArea.types"; diff --git a/src/components/index.ts b/src/components/index.ts index 66ea26d..0cbf216 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,7 @@ export * from "./Badge"; export * from "./Avatar"; export * from "./Skeleton"; export * from "./Checkbox"; +export * from "./ScrollArea"; // ── Theme ── export { ThemeProvider, useTheme, defaultTheme, darkTheme } from "../theme"; diff --git a/src/hooks/useReveal.ts b/src/hooks/useReveal.ts new file mode 100644 index 0000000..a705d8e --- /dev/null +++ b/src/hooks/useReveal.ts @@ -0,0 +1,46 @@ +import { useState, useEffect, useRef } from "react"; + +interface UseRevealOptions { + threshold?: number | number[]; + triggerOnce?: boolean; +} + +export const useReveal = ({ + threshold = 0, + triggerOnce = true, +}: UseRevealOptions = {}) => { + const [isVisible, setIsVisible] = useState(false); + const elementRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + + if (entry && entry.isIntersecting) { + setIsVisible(true); + if (triggerOnce && elementRef.current) { + observer.unobserve(elementRef.current); + } + } else if (!triggerOnce) { + setIsVisible(false); + } + }, + { threshold }, + ); + + const currentElement = elementRef.current; + + if (currentElement) { + observer.observe(currentElement); + } + + return () => { + if (currentElement) { + observer.unobserve(currentElement); + } + }; + }, [threshold, triggerOnce]); + + return { elementRef, isVisible }; +}; diff --git a/src/theme/DefaultTheme.ts b/src/theme/DefaultTheme.ts index b7c9b72..4ca59d9 100644 --- a/src/theme/DefaultTheme.ts +++ b/src/theme/DefaultTheme.ts @@ -8,16 +8,16 @@ import type { ThemeConfig } from "./Types"; const palette = { primary: { - 50: "#eff6ff", - 100: "#dbeafe", - 200: "#bfdbfe", - 300: "#93c5fd", - 400: "#60a5fa", - 500: "#3b82f6", - 600: "#2563eb", - 700: "#1d4ed8", - 800: "#1e40af", - 900: "#1e3a8a", + 50: "#fdf2f8", + 100: "#fce7f3", + 200: "#fbcfe8", + 300: "#f9a8d4", + 400: "#f472b6", + 500: "#ec4899", + 600: "#db2777", + 700: "#be185d", + 800: "#9d174d", + 900: "#831843", }, success: { 50: "#f0fdf4", diff --git a/src/theme/index.css b/src/theme/index.css index cf50957..68bcb81 100644 --- a/src/theme/index.css +++ b/src/theme/index.css @@ -56,3 +56,217 @@ ); animation: skeleton-wave 1.6s linear infinite; } + +/* ===== Modern Premium Scrollbar ===== */ + +.scrollbar { + overflow: auto; + scrollbar-width: thin; + /* Firefox */ + scrollbar-color: var(--color-border, rgba(150, 150, 150, 0.3)) transparent; +} + +/* Scrollbar size */ +.scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +/* Track */ +.scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +/* Thumb */ +.scrollbar::-webkit-scrollbar-thumb { + background-color: var(--color-border-subtle, rgba(150, 150, 150, 0.3)); + border-radius: 9999px; + background-clip: padding-box; + transition: background-color 0.2s ease-in-out; +} + +/* Hover */ +.scrollbar:hover::-webkit-scrollbar-thumb, +.scrollbar::-webkit-scrollbar-thumb:hover { + background-color: var(--color-border, rgba(150, 150, 150, 0.5)); +} + +/* Active (dragging) */ +.scrollbar::-webkit-scrollbar-thumb:active { + background-color: var(--color-text-subtle, rgba(150, 150, 150, 0.7)); +} + +/* Remove arrows */ +.scrollbar::-webkit-scrollbar-button { + display: none !important; + width: 0 !important; + height: 0 !important; +} + +/* Some Windows versions need this */ +.scrollbar::-webkit-scrollbar-button:start:decrement, +.scrollbar::-webkit-scrollbar-button:end:increment { + display: none !important; +} + +/* ===== Reveal Animations ===== */ +/* =============================== + Base Reveal Class +================================ */ + +.reveal { + opacity: 0; + will-change: transform, opacity, filter; + /* Add default transition in case no animation class is applied */ + transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* When element becomes visible */ +.reveal.is-visible { + opacity: 1; +} + +/* Optional stagger support */ +.reveal { + animation-delay: var(--delay, 0ms); +} + +/* =============================== + 1. Reveal Up +================================ */ + +@keyframes reveal-up { + from { + opacity: 0; + transform: translateY(40px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.reveal-up.is-visible { + animation: reveal-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* =============================== + 2. Reveal Down +================================ */ + +@keyframes reveal-down { + from { + opacity: 0; + transform: translateY(-40px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.reveal-down.is-visible { + animation: reveal-down 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* =============================== + 3. Reveal Left +================================ */ + +@keyframes reveal-left { + from { + opacity: 0; + transform: translateX(40px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.reveal-left.is-visible { + animation: reveal-left 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* =============================== + 4. Reveal Right +================================ */ + +@keyframes reveal-right { + from { + opacity: 0; + transform: translateX(-40px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.reveal-right.is-visible { + animation: reveal-right 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* =============================== + 5. Fade In +================================ */ + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.fade-in.is-visible { + animation: fade-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* =============================== + 6. Scale Pop +================================ */ + +@keyframes reveal-scale { + from { + opacity: 0; + transform: scale(0.9); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +.reveal-scale.is-visible { + animation: reveal-scale 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* =============================== + 7. Premium Blur Reveal +================================ */ + +@keyframes reveal-blur { + from { + opacity: 0; + transform: translateY(30px) scale(0.98); + filter: blur(12px); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.reveal-blur.is-visible { + animation: reveal-blur 1s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} From 3b01b355c0e20c82ed0f60a49249ee976dde8893 Mon Sep 17 00:00:00 2001 From: ABILASH <114130954+Abilash-19@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:52:33 +0530 Subject: [PATCH 06/17] docs: added documentation for scrollarea props --- src/components/ScrollArea/ScrollArea.types.ts | 100 ++++++++++++++---- 1 file changed, 77 insertions(+), 23 deletions(-) diff --git a/src/components/ScrollArea/ScrollArea.types.ts b/src/components/ScrollArea/ScrollArea.types.ts index 484f407..7ed02cd 100644 --- a/src/components/ScrollArea/ScrollArea.types.ts +++ b/src/components/ScrollArea/ScrollArea.types.ts @@ -1,29 +1,59 @@ +import type React from "react"; + +/** + * Properties for the ScrollArea container component. + * + * The `ScrollArea` component acts as a highly customizable wrapper that coordinates + * reveal-on-scroll animations for its nested `ScrollAreaItem` descendants. + */ export interface ScrollAreaProps { - /** Unique identifier for the scrollable container. */ + /** + * Unique identifier applied to the scrollable container. + * Useful for accessibility (`aria-controls`) and testing scenarios. + */ id?: string; - /** Ref forwarded to the underlying scroll element. */ - ref?: React.RefObject; + /** + * Ref forwarded internally to the underlying scroll container element (`
`). + * Can be leveraged to programmatically control scroll position. + */ + ref?: React.RefObject; - /** Content rendered inside the scrollable container. */ + /** + * Content rendered inside the scrollable container. + * This typically includes standard HTML elements and `ScrollAreaItem` components + * which handle individual entry animations. + */ children: React.ReactNode; - /** Additional class names applied to the container. */ + /** + * Optional custom CSS class names to apply to the scroll container. + */ className?: string; - /** Inline styles applied to the container. */ + /** + * Optional inline styles to apply directly to the scroll container viewport. + */ style?: React.CSSProperties; - /** Callback fired when the container is scrolled. */ + /** + * Callback fired synchronously when the user scrolls the container's contents. + * + * @param event - The native UI event fired by the underlying `HTMLDivElement`. + */ onScroll?: (event: React.UIEvent) => void; /** - * Controls the reveal animation applied to child items on scroll. - * - `"up"` / `"down"` / `"left"` / `"right"` — slides in from a direction - * - `"fade"` — fades in - * - `"scale"` — scales up from center - * - `"blur"` — unblurs into view - * @defaultValue `"fade"` + * Defines the default reveal animation applied to all child `ScrollAreaItem` descendant + * items when they enter the viewport. Items can override this using their own `revealType`. + * + * @remarks + * - `"up"`, `"down"`, `"left"`, `"right"`: Directional slide-in translations. + * - `"fade"`: Simple opacity transition from 0% to 100%. + * - `"scale"`: Zooms in fluidly from a lower scale bound. + * - `"blur"`: Transitions from a gaussian blur down into sharp focus. + * + * @defaultValue `"scale"` */ ScrollAnimation?: | "up" @@ -35,32 +65,56 @@ export interface ScrollAreaProps { | "blur"; } +/** + * Properties for an individual scroll-observable item. + * + * Specifically designed to be composed within a `ScrollArea` container. It leverages + * the intersection observer to trigger entrance and reveal animations. + */ export interface ScrollAreaItemProps { - /** Content rendered inside the scroll item. */ + /** + * The content to be wrapped and naturally revealed when intersected by the viewport. + */ children: React.ReactNode; - /** Additional class names applied to the item. */ + /** + * Optional custom CSS class names applied to the wrapper executing the intersection observer. + */ className?: string; - /** Inline styles applied to the item. */ + /** + * Optional inline styles applied directly to the observer wrapper element. + */ style?: React.CSSProperties; /** - * When `true`, the reveal animation only plays the first time the item - * enters the viewport. When `false`, it replays on every re-entry. + * Determines whether the reveal animation occurs only a single time or repeatedly. + * + * - If `true`, the animation plays the first time the item is visible and remains unobserved mapping to a finalized state. + * - If `false`, the animation replays every single time the item crosses the threshold back into the viewport's context. + * * @defaultValue `true` */ triggerOnce?: boolean; /** - * When `true`, the reveal animation only plays the first time the item - * enters the viewport. When `false`, it replays on every re-entry. - * @defaultValue `true` + * Set the threshold ratio indicating the percentage of the item's visibility required + * to trigger the reveal animation callbacks. + * + * - A value of `0.0` reveals instantly when any single pixel enters the viewport. + * - A value of `0.5` requires 50% visibility prior to executing the animation. + * - A value of `1.0` dictates the entire element must be thoroughly seen. + * + * @defaultValue 0.2 */ threshold?: number; + /** - * Overrides the parent `ScrollArea` animation for this specific item. - * Inherits from `ScrollAreaProps.ScrollAnimation` when omitted. + * Disables the parent container's `ScrollAnimation` setting exclusively for this particular item. + * Highly useful for constructing complex cascades where certain hero items enter differently + * from the standard unified grid items. + * + * Inherits strictly from `ScrollAreaProps["ScrollAnimation"]` when left omitted. */ revealType?: NonNullable; } From f9d34f3db948998cdc5eb05d4433548eeabd38b6 Mon Sep 17 00:00:00 2001 From: ABILASH <114130954+Abilash-19@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:53:32 +0530 Subject: [PATCH 07/17] fix: added threshold to e user defined --- src/components/ScrollArea/ScrollArea.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ScrollArea/ScrollArea.tsx b/src/components/ScrollArea/ScrollArea.tsx index 968621f..e233900 100644 --- a/src/components/ScrollArea/ScrollArea.tsx +++ b/src/components/ScrollArea/ScrollArea.tsx @@ -37,8 +37,9 @@ export const ScrollAreaItem = ({ style, triggerOnce, revealType = "scale", + threshold = 0.1, }: ScrollAreaItemProps) => { - const { elementRef, isVisible } = useReveal({ triggerOnce }); + const { elementRef, isVisible } = useReveal({ triggerOnce, threshold }); const ANIMATION_CLASS_MAP: Record< NonNullable, @@ -63,7 +64,6 @@ export const ScrollAreaItem = ({
); }; -console.log(Object.assign(ScrollAreaComponent, { item: ScrollAreaItem })); export { ScrollAreaComponent as ScrollArea }; export { ScrollAreaItem as ScrollElement }; From e3a988ce1efd1a448dd35caad008783eaaac28c5 Mon Sep 17 00:00:00 2001 From: ABILASH <114130954+Abilash-19@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:55:05 +0530 Subject: [PATCH 08/17] style: typography and padding enhanced --- src/components/Accordion/Accordion.tsx | 20 ++++++++++---------- src/components/Input/Input.tsx | 25 +++++++++++++++---------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx index be4ac9a..ffc4bf1 100644 --- a/src/components/Accordion/Accordion.tsx +++ b/src/components/Accordion/Accordion.tsx @@ -14,7 +14,7 @@ const Accordion = forwardRef( children, isDefaultOpen = false, isDisabled = false, - variant = "borderless", + variant = "border", }, ref, ) => { @@ -57,30 +57,30 @@ const Accordion = forwardRef( className={cn( "flex cursor-pointer items-center justify-between gap-4", "text-left select-none", - "transition-all duration-200", + "transition-all duration-300 ease-out", "text-[var(--accordion-text)]", !isBorderless && [ "px-5 py-4", - "hover:bg-[var(--accordion-bg-hover)] rounded-[var(--accordion-radius)]", + "hover:bg-[var(--accordion-bg-hover)] rounded-[var(--accordion-radius)] active:scale-[0.99]", ], isBorderless && [ "px-0 py-3", - "hover:bg-[var(--accordion-bg-hover)]/40", + "hover:opacity-80 active:scale-[0.99] origin-left", ], "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accordion-border)] focus-visible:ring-offset-2", )} > -
+
{title && ( - + {title} )} {subtitle && ( - + {subtitle} )} @@ -98,11 +98,11 @@ const Accordion = forwardRef(
{children} diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 2cc1df3..ffab976 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -44,8 +44,8 @@ const Input = forwardRef( }, [errorId, helperId]); const sizes = { - sm: "h-9 text-sm", - md: "h-11 text-sm", + sm: "h-8 text-xs", + md: "h-10 text-sm", lg: "h-12 text-base", }; @@ -68,6 +68,7 @@ const Input = forwardRef(