Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4310bac
refactor: add reusable Button component with variants, sizes, loading…
SyedHannanMehdi Mar 29, 2026
c77169b
refactor: add accessible Button component with variants/sizes/loading
SyedHannanMehdi Mar 29, 2026
c960245
refactor: add composable Card/CardHeader/CardBody/CardFooter components
SyedHannanMehdi Mar 29, 2026
5e6a997
refactor: add accessible Input component with label, hint, error, addons
SyedHannanMehdi Mar 29, 2026
1962592
refactor: add Badge component with semantic variants
SyedHannanMehdi Mar 29, 2026
97d5dc9
refactor: add accessible Modal component with focus trap, Escape key,…
SyedHannanMehdi Mar 29, 2026
bb45658
refactor: add Skeleton loading components
SyedHannanMehdi Mar 29, 2026
04bd4a7
refactor: add Toast notification system with context provider
SyedHannanMehdi Mar 29, 2026
18bf725
refactor: add accessible Select component with generic typing
SyedHannanMehdi Mar 29, 2026
556876d
fix: add missing cn utility to client/src/lib/utils.ts
SyedHannanMehdi Mar 29, 2026
a858d5e
fix: move Button to client/src/components/ui and fix cn import path
SyedHannanMehdi Mar 29, 2026
60eb9bf
fix: move Input to client/src/components/ui, fix cn import, use React…
SyedHannanMehdi Mar 29, 2026
3df69b1
fix: move Select to client/src/components/ui and fix cn import path
SyedHannanMehdi Mar 29, 2026
93d2836
fix: move Modal to client/src/components/ui and fix cn import path
SyedHannanMehdi Mar 29, 2026
f5bd29c
fix: move Toast to client/src/components/ui and fix cn import path
SyedHannanMehdi Mar 29, 2026
ea3395c
fix: move Skeleton to client/src/components/ui and fix cn import path
SyedHannanMehdi Mar 29, 2026
c32f505
fix: move Card to client/src/components/ui and fix cn import path
SyedHannanMehdi Mar 29, 2026
b88ee74
fix: move Badge to client/src/components/ui and fix cn import path
SyedHannanMehdi Mar 29, 2026
7d947f9
refactor: add reusable Button component with variants, sizes, and loa…
SyedHannanMehdi Mar 29, 2026
849d686
refactor: add reusable Card component with optional title/action header
SyedHannanMehdi Mar 29, 2026
e319add
refactor: add accessible Input component with label, error, hint and …
SyedHannanMehdi Mar 29, 2026
d2714b1
refactor: add Badge component for status indicators
SyedHannanMehdi Mar 29, 2026
5ce8c8b
refactor: add accessible Spinner component
SyedHannanMehdi Mar 29, 2026
b976357
refactor: add lightweight Tooltip component with placement support
SyedHannanMehdi Mar 29, 2026
f6271da
refactor: add accessible Modal component with portal, focus trap, and…
SyedHannanMehdi Mar 29, 2026
7cd915a
refactor: add accessible Select component
SyedHannanMehdi Mar 29, 2026
aaf7fe1
feat(ui): add cn utility to client/src/lib/utils
SyedHannanMehdi Mar 29, 2026
e1289c3
feat(ui): move Button to client/src and fix cn import
SyedHannanMehdi Mar 29, 2026
b0d905f
fix(ui): use React.useId() in Input to avoid undefined aria-described…
SyedHannanMehdi Mar 29, 2026
1d98831
feat(ui): move Select to client/src, fix cn import and stable IDs
SyedHannanMehdi Mar 29, 2026
9fa8419
feat(ui): move Modal to client/src and fix cn import
SyedHannanMehdi Mar 29, 2026
728a251
feat(ui): move Toast to client/src and fix cn import
SyedHannanMehdi Mar 29, 2026
ac620ef
feat(ui): move Skeleton to client/src and fix cn import
SyedHannanMehdi Mar 29, 2026
864518e
feat(ui): move Card to client/src and fix cn import
SyedHannanMehdi Mar 29, 2026
6db888c
feat(ui): move Badge to client/src and fix cn import
SyedHannanMehdi Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions client/src/components/ui/Badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from "react";
import { cn } from "../../lib/utils";

/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */

export type BadgeVariant =
| "default"
| "primary"
| "success"
| "warning"
| "danger"
| "outline";

export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
/** Show a colored dot before the label */
dot?: boolean;
}

/* ------------------------------------------------------------------ */
/* Styles */
/* ------------------------------------------------------------------ */

const variantClasses: Record<BadgeVariant, string> = {
default: "bg-gray-100 text-gray-700",
primary: "bg-blue-100 text-blue-700",
success: "bg-green-100 text-green-700",
warning: "bg-yellow-100 text-yellow-700",
danger: "bg-red-100 text-red-700",
outline: "border border-gray-300 bg-transparent text-gray-700",
};

const dotVariantClasses: Record<BadgeVariant, string> = {
default: "bg-gray-400",
primary: "bg-blue-500",
success: "bg-green-500",
warning: "bg-yellow-500",
danger: "bg-red-500",
outline: "bg-gray-400",
};

/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */

export function Badge({
variant = "default",
dot = false,
className,
children,
...props
}: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
variantClasses[variant],
className
)}
{...props}
>
{dot && (
<span
aria-hidden="true"
className={cn("h-1.5 w-1.5 rounded-full", dotVariantClasses[variant])}
/>
)}
{children}
</span>
);
}
87 changes: 87 additions & 0 deletions client/src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from "react";
import { cn } from "../../lib/utils";

export type ButtonVariant =
| "primary"
| "secondary"
| "ghost"
| "danger"
| "outline";
export type ButtonSize = "sm" | "md" | "lg";

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}

const variantClasses: Record<ButtonVariant, string> = {
primary:
"bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500",
secondary:
"bg-gray-200 text-gray-900 hover:bg-gray-300 focus-visible:ring-gray-400",
ghost:
"bg-transparent text-gray-700 hover:bg-gray-100 focus-visible:ring-gray-400",
danger:
"bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
outline:
"border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 focus-visible:ring-gray-400",
};

const sizeClasses: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = "primary",
size = "md",
loading = false,
leftIcon,
rightIcon,
children,
disabled,
className,
...props
},
ref
) => {
const isDisabled = disabled || loading;

return (
<button
ref={ref}
disabled={isDisabled}
aria-busy={loading || undefined}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
>
{loading ? (
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
/>
) : (
leftIcon
)}
{children}
{!loading && rightIcon}
</button>
);
}
);

Button.displayName = "Button";
80 changes: 80 additions & 0 deletions client/src/components/ui/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from "react";
import { cn } from "../../lib/utils";

/* ------------------------------------------------------------------ */
/* Card */
/* ------------------------------------------------------------------ */

export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Remove default padding */
noPadding?: boolean;
}

export function Card({ noPadding = false, className, children, ...props }: CardProps) {
return (
<div
className={cn(
"rounded-lg border border-gray-200 bg-white shadow-sm",
!noPadding && "p-4",
className
)}
{...props}
>
{children}
</div>
);
}

/* ------------------------------------------------------------------ */
/* Card.Header */
/* ------------------------------------------------------------------ */

export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}

export function CardHeader({ className, children, ...props }: CardHeaderProps) {
return (
<div
className={cn(
"flex items-center justify-between border-b border-gray-100 px-4 py-3",
className
)}
{...props}
>
{children}
</div>
);
}

/* ------------------------------------------------------------------ */
/* Card.Body */
/* ------------------------------------------------------------------ */

export interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {}

export function CardBody({ className, children, ...props }: CardBodyProps) {
return (
<div className={cn("px-4 py-4", className)} {...props}>
{children}
</div>
);
}

/* ------------------------------------------------------------------ */
/* Card.Footer */
/* ------------------------------------------------------------------ */

export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {}

export function CardFooter({ className, children, ...props }: CardFooterProps) {
return (
<div
className={cn(
"flex items-center border-t border-gray-100 px-4 py-3",
className
)}
{...props}
>
{children}
</div>
);
}
106 changes: 106 additions & 0 deletions client/src/components/ui/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React from "react";
import { cn } from "../../lib/utils";

export type InputSize = "sm" | "md" | "lg";

export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
label?: string;
hint?: string;
error?: string;
size?: InputSize;
leftAddon?: React.ReactNode;
rightAddon?: React.ReactNode;
}

const sizeClasses: Record<InputSize, string> = {
sm: "h-8 px-2 text-sm",
md: "h-10 px-3 text-sm",
lg: "h-12 px-4 text-base",
};

export const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
label,
hint,
error,
size = "md",
leftAddon,
rightAddon,
id,
className,
...props
},
ref
) => {
// Always generate a stable fallback ID so aria-describedby values are never
// built from "undefined" even when neither `id` nor `label` is provided.
const generatedId = React.useId();
const inputId = id ?? generatedId;

const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy =
[hintId, errorId].filter(Boolean).join(" ") || undefined;

return (
<div className="flex flex-col gap-1">
{label && (
<label
htmlFor={inputId}
className="text-sm font-medium text-gray-700"
>
{label}
</label>
)}

<div className="relative flex items-center">
{leftAddon && (
<span className="pointer-events-none absolute left-3 text-gray-400">
{leftAddon}
</span>
)}

<input
ref={ref}
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
className={cn(
"w-full rounded-md border bg-white transition-colors",
"placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-0",
error
? "border-red-400 focus:ring-red-400"
: "border-gray-300 focus:ring-blue-500",
leftAddon && "pl-9",
rightAddon && "pr-9",
sizeClasses[size],
className
)}
{...props}
/>

{rightAddon && (
<span className="pointer-events-none absolute right-3 text-gray-400">
{rightAddon}
</span>
)}
</div>

{error && (
<p id={errorId} className="text-xs text-red-600" role="alert">
{error}
</p>
)}
{hint && !error && (
<p id={hintId} className="text-xs text-gray-500">
{hint}
</p>
)}
</div>
);
}
);

Input.displayName = "Input";
Loading