closeOnOverlayClick && onClose()}
+ />
+
{
+ if (!closeOnOverlayClick) return;
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+ {title &&
{title} }
+
+ ✕
+
+
+
{children}
+ {footer &&
{footer}
}
+
+
+ >
+ );
+
+ return createPortal(content, document.body);
+};
+
+export default Dialog;
diff --git a/client/src/ui/IconButton.stories.tsx b/client/src/ui/IconButton.stories.tsx
new file mode 100644
index 0000000..c6bb532
--- /dev/null
+++ b/client/src/ui/IconButton.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import IconButton from './IconButton';
+
+const meta: Meta
= {
+ title: 'UI/IconButton',
+ component: IconButton,
+ tags: ['autodocs'],
+ parameters: {
+ docs: { source: { type: 'dynamic' } },
+ },
+ args: {
+ children: ✚ ,
+ 'aria-label': 'Add',
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Small: Story = { args: { size: 'small' } };
+export const Large: Story = { args: { size: 'large' } };
+export const EdgeStart: Story = { args: { edge: 'start' } };
+export const EdgeEnd: Story = { args: { edge: 'end' } };
+export const NoRipple: Story = { args: { ripple: false } };
+export const Disabled: Story = { args: { disabled: true } };
+
+export const StaticExample: Story = {
+ name: 'Static example (copy-paste)',
+ parameters: {
+ docs: {
+ source: {
+ code: `import IconButton from './IconButton';
+
+export default function Page() {
+ return (
+
+ ✚
+ ✚
+ ✚
+ ≡
+ ×
+ ✚
+ ✚
+
+ );
+}`,
+ },
+ },
+ },
+ render: () => (
+
+ ✚
+ ✚
+ ✚
+ ≡
+ ×
+ ✚
+ ✚
+
+ ),
+};
diff --git a/client/src/ui/IconButton.tsx b/client/src/ui/IconButton.tsx
new file mode 100644
index 0000000..7757304
--- /dev/null
+++ b/client/src/ui/IconButton.tsx
@@ -0,0 +1,96 @@
+import React, { forwardRef, useState } from 'react';
+
+export interface IconButtonProps extends React.ButtonHTMLAttributes {
+ children: React.ReactNode;
+ size?: 'small' | 'medium' | 'large';
+ disabled?: boolean;
+ ripple?: boolean;
+ edge?: 'start' | 'end' | 'false';
+}
+
+const IconButton = forwardRef(({
+ children,
+ size = 'medium',
+ disabled = false,
+ ripple = true,
+ edge = 'false',
+ className = '',
+ onMouseDown,
+ ...props
+}, ref) => {
+ const [ripples, setRipples] = useState>([]);
+
+ const handleMouseDown = (event: React.MouseEvent) => {
+ if (ripple && !disabled) {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ const y = event.clientY - rect.top;
+ const newRipple = { id: Date.now(), x, y };
+
+ setRipples(prev => [...prev, newRipple]);
+
+ setTimeout(() => {
+ setRipples(prev => prev.filter(r => r.id !== newRipple.id));
+ }, 600);
+ }
+
+ onMouseDown?.(event);
+ };
+
+ const sizeClasses = {
+ small: 'w-8 h-8 p-1',
+ medium: 'w-10 h-10 p-2',
+ large: 'w-12 h-12 p-3'
+ };
+
+ const edgeClasses = {
+ start: '-ml-3',
+ end: '-mr-3',
+ false: ''
+ };
+
+ const baseClasses = `
+ relative overflow-hidden
+ inline-flex items-center justify-center
+ rounded-full
+ transition-all duration-200 ease-in-out
+ focus:outline-none focus:ring-2 focus:ring-offset-2
+ disabled:pointer-events-none disabled:opacity-60
+ ${sizeClasses[size]}
+ ${edgeClasses[edge]}
+ `.trim().replace(/\s+/g, ' ');
+
+ return (
+
+
+ {children}
+
+
+ {/* Ripple effect */}
+ {ripples.map(ripple => (
+
+ ))}
+
+ );
+});
+
+IconButton.displayName = 'IconButton';
+
+export default IconButton;
diff --git a/client/src/ui/Select.stories.tsx b/client/src/ui/Select.stories.tsx
new file mode 100644
index 0000000..43053e3
--- /dev/null
+++ b/client/src/ui/Select.stories.tsx
@@ -0,0 +1,78 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import Select, { type SelectProps } from './Select';
+import React from 'react';
+
+const meta: Meta = {
+ title: 'UI/Select',
+ component: Select,
+ tags: ['autodocs'],
+ parameters: { docs: { source: { type: 'dynamic' } } },
+ args: {
+ value: null,
+ options: [
+ { label: 'Food', value: 'food' },
+ { label: 'Transport', value: 'transport' },
+ { label: 'Utilities', value: 'utilities' },
+ { label: 'Shopping', value: 'shopping' },
+ ],
+ placeholder: 'Select category',
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Playground: Story = {
+ render: (args) => {
+ const [value, setValue] = React.useState(args.value ?? null);
+ return (
+
+
+
+ );
+ },
+};
+
+export const StaticExample: Story = {
+ name: 'Static example (copy-paste)',
+ parameters: {
+ docs: {
+ source: {
+ code: `import Select from './Select';
+
+export default function Page() {
+ const [value, setValue] = React.useState(null);
+ return (
+
+ );
+}`,
+ },
+ },
+ },
+ render: () => {
+ const [value, setValue] = React.useState(null);
+ return (
+
+ );
+ },
+};
diff --git a/client/src/ui/Select.tsx b/client/src/ui/Select.tsx
new file mode 100644
index 0000000..6336c18
--- /dev/null
+++ b/client/src/ui/Select.tsx
@@ -0,0 +1,212 @@
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+export interface SelectOption {
+ label: string;
+ value: T;
+ disabled?: boolean;
+}
+
+export interface SelectClasses {
+ root?: string;
+ trigger?: string;
+ icon?: string;
+ list?: string;
+ option?: string;
+ label?: string;
+ helperText?: string;
+ error?: string;
+}
+
+export interface SelectProps {
+ value: T | null;
+ onChange: (value: T) => void;
+ options: SelectOption[];
+ placeholder?: string;
+ disabled?: boolean;
+ error?: boolean;
+ helperText?: string;
+ classes?: SelectClasses;
+}
+
+const Select = ({
+ value,
+ onChange,
+ options,
+ placeholder = 'Select…',
+ disabled,
+ error,
+ helperText,
+ classes,
+}: SelectProps) => {
+ const id = useId();
+ const triggerRef = useRef(null);
+ const listRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [coords, setCoords] = useState<{ left: number; top: number; width: number }>({ left: 0, top: 0, width: 0 });
+ const [query, setQuery] = useState('');
+ const [activeIndex, setActiveIndex] = useState(-1);
+
+ const selected = useMemo(() => options.find(o => o.value === value) ?? null, [options, value]);
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return options;
+ return options.filter(o => o.label.toLowerCase().includes(q));
+ }, [options, query]);
+
+ const updatePosition = useCallback(() => {
+ const t = triggerRef.current;
+ if (!t) return;
+ const r = t.getBoundingClientRect();
+ setCoords({ left: r.left, top: r.bottom + 4, width: r.width });
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+ updatePosition();
+ const onResize = () => updatePosition();
+ const onScroll = () => updatePosition();
+ window.addEventListener('resize', onResize);
+ window.addEventListener('scroll', onScroll, true);
+ return () => {
+ window.removeEventListener('resize', onResize);
+ window.removeEventListener('scroll', onScroll, true);
+ };
+ }, [open, updatePosition]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setOpen(false);
+ };
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDocClick = (e: MouseEvent) => {
+ const t = e.target as Node;
+ if (listRef.current?.contains(t) || triggerRef.current?.contains(t)) return;
+ setOpen(false);
+ };
+ document.addEventListener('mousedown', onDocClick);
+ return () => document.removeEventListener('mousedown', onDocClick);
+ }, [open]);
+
+ const rootCls = ['relative', classes?.root].filter(Boolean).join(' ');
+ const triggerCls = [
+ 'w-full rounded-md border px-3 py-2 text-sm',
+ 'bg-white text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300',
+ 'focus:outline-none focus:ring-2 focus:ring-sky-500',
+ disabled ? 'opacity-60 cursor-not-allowed' : '',
+ error ? 'border-red-300 ring-red-300' : 'border-gray-300',
+ classes?.trigger,
+ ].filter(Boolean).join(' ');
+
+ const listCls = [
+ 'fixed z-[1002] max-h-64 w-[--select-w] overflow-auto rounded-md border bg-white shadow-lg',
+ 'ring-1 ring-gray-200 p-1',
+ classes?.list,
+ ].filter(Boolean).join(' ');
+
+ const optionBase = [
+ 'flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm',
+ 'text-gray-800 hover:bg-gray-100 aria-selected:bg-gray-100 aria-disabled:opacity-50',
+ classes?.option,
+ ].filter(Boolean).join(' ');
+
+ const iconCls = ['ml-2 text-gray-500', classes?.icon].filter(Boolean).join(' ');
+ const helperCls = ['mt-1 text-xs', error ? 'text-red-600' : 'text-gray-500', classes?.helperText].filter(Boolean).join(' ');
+
+ const list = (
+
+ {filtered.map((opt, idx) => (
+ setActiveIndex(idx)}
+ onClick={() => {
+ if (opt.disabled) return;
+ onChange(opt.value);
+ setOpen(false);
+ setQuery('');
+ setActiveIndex(-1);
+ }}
+ >
+ {opt.label}
+
+ ))}
+
+ );
+
+ return (
+
+
+
+ {
+ setQuery(e.target.value);
+ if (!open) setOpen(true);
+ setActiveIndex(0);
+ }}
+ onFocus={() => {
+ if (!disabled) {
+ setOpen(true);
+ setQuery('');
+ setActiveIndex(0);
+ }
+ }}
+ onKeyDown={(e) => {
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) {
+ setOpen(true); setActiveIndex(0); return;
+ }
+ if (!open) return;
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setActiveIndex((i) => Math.max(i - 1, 0));
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ const opt = filtered[activeIndex];
+ if (opt && !opt.disabled) {
+ onChange(opt.value);
+ setOpen(false);
+ setQuery('');
+ }
+ }
+ }}
+ onClick={() => {
+ if (!disabled) setOpen(true);
+ }}
+ />
+ ▾
+
+ {helperText &&
{helperText}
}
+ {open && createPortal(list, document.body)}
+
+ );
+};
+
+export default Select;
diff --git a/client/src/ui/Skeleton.stories.tsx b/client/src/ui/Skeleton.stories.tsx
new file mode 100644
index 0000000..3e46b4a
--- /dev/null
+++ b/client/src/ui/Skeleton.stories.tsx
@@ -0,0 +1,50 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import Skeleton, { type SkeletonProps } from './Skeleton';
+
+const meta: Meta = {
+ title: 'UI/Skeleton',
+ component: Skeleton,
+ tags: ['autodocs'],
+ parameters: { docs: { source: { type: 'dynamic' } } },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Variants: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const StaticExample: Story = {
+ name: 'Static example (copy-paste)',
+ parameters: {
+ docs: {
+ source: {
+ code: `import Skeleton from './Skeleton';
+
+export default function Page() {
+ return (
+
+
+
+
+
+ );
+}`,
+ },
+ },
+ },
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/client/src/ui/Skeleton.tsx b/client/src/ui/Skeleton.tsx
new file mode 100644
index 0000000..09ef21c
--- /dev/null
+++ b/client/src/ui/Skeleton.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+export interface SkeletonClasses {
+ root?: string;
+}
+
+export interface SkeletonProps extends React.HTMLAttributes {
+ variant?: 'text' | 'rect' | 'circle';
+ width?: number | string;
+ height?: number | string;
+ rounded?: string;
+ classes?: SkeletonClasses;
+}
+
+const Skeleton: React.FC = ({
+ variant = 'rect',
+ width,
+ height,
+ rounded,
+ className,
+ classes,
+ style,
+ ...rest
+}) => {
+ const base = [
+ 'relative overflow-hidden bg-gray-200',
+ variant === 'text' ? 'h-4' : '',
+ variant === 'circle' ? 'rounded-full' : '',
+ rounded,
+ className,
+ classes?.root,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ const inlineStyle: React.CSSProperties = {
+ width,
+ height,
+ ...style,
+ };
+
+ return (
+
+ );
+};
+
+export default Skeleton;
diff --git a/client/src/ui/TextField.stories.tsx b/client/src/ui/TextField.stories.tsx
new file mode 100644
index 0000000..a99f911
--- /dev/null
+++ b/client/src/ui/TextField.stories.tsx
@@ -0,0 +1,184 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React, { useState } from 'react';
+import { within, userEvent, expect } from 'storybook/test';
+import TextField, { type TextFieldProps } from './TextField';
+import IconButton from './IconButton';
+
+const meta: Meta = {
+ title: 'UI/TextField',
+ component: TextField,
+ tags: ['autodocs'],
+ parameters: {
+ controls: { expanded: true },
+ docs: { source: { type: 'dynamic' } },
+ },
+ argTypes: {
+ value: { control: 'text' },
+ label: { control: 'text' },
+ placeholder: { control: 'text' },
+ helperText: { control: 'text' },
+ error: { control: 'boolean' },
+ disabled: { control: 'boolean' },
+ fullWidth: { control: 'boolean' },
+ variant: { control: { type: 'select' }, options: ['outlined', 'filled', 'standard'] },
+ size: { control: { type: 'inline-radio' }, options: ['small', 'medium', 'large'] },
+ classes: { control: 'object' },
+ },
+ args: {
+ value: '',
+ label: 'Name',
+ placeholder: 'Your name',
+ helperText: '',
+ error: false,
+ disabled: false,
+ fullWidth: false,
+ variant: 'outlined',
+ size: 'medium',
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+const Controlled: React.FC> = (props) => {
+ const [val, setVal] = useState('');
+ return (
+
+ setVal(e.target.value)}
+ />
+
+ );
+};
+
+export const Playground: Story = {
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const c = within(canvasElement);
+ const label = args.label || args.placeholder || '';
+ const input = await c.findByLabelText(label);
+ await userEvent.click(input);
+ await userEvent.type(input, 'Hello');
+ expect((input as HTMLInputElement).value).toBe('Hello');
+ },
+};
+
+export const WithAdornments: Story = {
+ name: 'With adornments',
+ args: {
+ label: 'Amount',
+ },
+ render: (args) => (
+
+ €}
+ placeholder="0.00"
+ />
+ ✕}
+ placeholder="Type something"
+ />
+
+ ),
+ play: async ({ canvasElement, args }) => {
+ const c = within(canvasElement);
+ const euro = await c.findByText('€');
+ expect(euro).toBeTruthy();
+ const labelEl = await c.findByText(args.label as string);
+ expect(labelEl.className).toContain('left-12');
+ const clearBtn = await c.findByRole('button', { name: /clear/i });
+ expect(clearBtn).toBeTruthy();
+ },
+};
+
+export const ErrorState: Story = {
+ name: 'Error state',
+ args: {
+ label: 'Email',
+ helperText: 'Invalid format',
+ error: true,
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const c = within(canvasElement);
+ const input = await c.findByLabelText(args.label as string);
+ expect(input).toHaveAttribute('aria-invalid', 'true');
+ await c.findByText(/invalid format/i);
+ },
+};
+
+export const WithCustomClasses: Story = {
+ name: 'With custom classes',
+ args: {
+ label: 'Search',
+ helperText: 'Easily customize with Tailwind',
+ classes: {
+ root: 'group',
+ input: 'bg-slate-50 focus:bg-white',
+ label: 'group-focus-within:text-sky-600',
+ helperText: 'italic',
+ },
+ },
+ render: (args) => ,
+ play: async ({ canvasElement, args }) => {
+ const c = within(canvasElement);
+ const input = await c.findByLabelText(args.label as string);
+ expect((input as HTMLElement).className).toContain('bg-slate-50');
+ await userEvent.click(input);
+ await userEvent.type(input, 'Test');
+ expect((input as HTMLInputElement).value).toBe('Test');
+ },
+};
+
+export const CustomBackgrounds: Story = {
+ name: 'Custom backgrounds (label matches input)',
+ render: (args) => (
+
+ {/* Filled: label bg matches automatically (bg-gray-50) */}
+
+
+ {/* Outlined with custom bg: pass same bg to label via classes.label */}
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const c = within(canvasElement);
+ await c.findByText(/Filled default bg/i);
+ await c.findByText(/Outlined custom bg/i);
+ },
+};
+
+export const Sizes: Story = {
+ name: 'Sizes (small / medium / large)',
+ render: (args) => (
+
+ ),
+};
diff --git a/client/src/ui/TextField.tsx b/client/src/ui/TextField.tsx
new file mode 100644
index 0000000..1b7059e
--- /dev/null
+++ b/client/src/ui/TextField.tsx
@@ -0,0 +1,171 @@
+import React, { useId, forwardRef, useMemo } from 'react';
+import { SIZE_PADDING_MAP, LABEL_BASE } from './constants/inputs';
+import {
+ TEXTFIELD_BASE_INTERACTIVE,
+ TEXTFIELD_OUTLINED,
+ TEXTFIELD_OUTLINED_OK,
+ TEXTFIELD_OUTLINED_ERR,
+ TEXTFIELD_FILLED,
+ TEXTFIELD_FILLED_OK,
+ TEXTFIELD_FILLED_ERR,
+ TEXTFIELD_STANDARD,
+ TEXTFIELD_STANDARD_OK,
+ TEXTFIELD_STANDARD_ERR,
+ labelBgByVariant,
+ labelFocusBgByVariant,
+} from './constants/textfield';
+
+export interface TextFieldClasses {
+ root?: string;
+ input?: string;
+ label?: string;
+ helperText?: string;
+ error?: string;
+ startAdornment?: string;
+ endAdornment?: string;
+}
+
+export interface TextFieldProps extends Omit, 'size'> {
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+ label?: string; // If provided, used for floating label. Falls back to placeholder.
+ helperText?: string;
+ error?: boolean;
+ fullWidth?: boolean;
+ startAdornment?: React.ReactNode;
+ endAdornment?: React.ReactNode;
+ classes?: TextFieldClasses;
+ variant?: 'outlined' | 'filled' | 'standard';
+ size?: 'small' | 'medium' | 'large';
+}
+
+const TextField = forwardRef(({
+ value,
+ onChange,
+ label,
+ placeholder,
+ helperText,
+ error,
+ disabled,
+ fullWidth,
+ startAdornment,
+ endAdornment,
+ className,
+ classes,
+ type = 'text',
+ variant = 'outlined',
+ size = 'medium',
+ ...props
+}, ref) => {
+ const id = useId();
+ const renderLabel = label ?? placeholder ?? '';
+
+ const rootCls = [
+ 'relative',
+ fullWidth ? 'w-full' : '',
+ classes?.root,
+ className,
+ ].filter(Boolean).join(' ');
+
+ // Sizing
+ // large ≈56px (previous medium)
+ // medium ≈52px (slightly reduced)
+ // small ≈48px
+ const sizePadding = SIZE_PADDING_MAP[size];
+ const baseInteractive = TEXTFIELD_BASE_INTERACTIVE;
+
+ const outlinedStyles = [TEXTFIELD_OUTLINED, error ? TEXTFIELD_OUTLINED_ERR : TEXTFIELD_OUTLINED_OK].join(' ');
+ const filledStyles = [TEXTFIELD_FILLED, error ? TEXTFIELD_FILLED_ERR : TEXTFIELD_FILLED_OK].join(' ');
+ const standardStyles = [TEXTFIELD_STANDARD, error ? TEXTFIELD_STANDARD_ERR : TEXTFIELD_STANDARD_OK].join(' ');
+
+ const variantCls = variant === 'outlined' ? outlinedStyles : variant === 'filled' ? filledStyles : standardStyles;
+
+ const inputCls = [
+ baseInteractive,
+ sizePadding,
+ variantCls,
+ disabled ? 'opacity-60 cursor-not-allowed' : '',
+ startAdornment ? 'pl-12' : '',
+ endAdornment ? 'pr-12' : '',
+ classes?.input,
+ ].filter(Boolean).join(' ');
+
+ const labelBase = useMemo(() => LABEL_BASE.replace('peer-focus:px-1', 'peer-focus:px-1 peer-focus:font-medium'), []);
+
+ const labelLeft = startAdornment ? 'left-8' : 'left-4';
+
+ const labelBg = labelBgByVariant(variant);
+ const labelFocusBg = labelFocusBgByVariant(variant);
+ const labelRaised = `${labelBg} peer-placeholder-shown:bg-transparent ${labelFocusBg}`;
+
+ const helperCls = [
+ 'mt-1 text-sm',
+ error ? 'text-red-600' : 'text-gray-500',
+ classes?.helperText,
+ ].filter(Boolean).join(' ');
+
+ const helperId = helperText ? `${id}-helper` : undefined;
+
+ return (
+
+
+ {startAdornment && (
+
+ {startAdornment}
+
+ )}
+
+ {renderLabel && (
+
+ {renderLabel}
+
+ )}
+ {endAdornment && (
+
+ {endAdornment}
+
+ )}
+
+ {helperText && (
+
{helperText}
+ )}
+ {error && (
+
+ )}
+
+ );
+});
+
+TextField.displayName = 'TextField';
+
+export default TextField;
diff --git a/client/src/ui/Toast.stories.tsx b/client/src/ui/Toast.stories.tsx
new file mode 100644
index 0000000..112dd52
--- /dev/null
+++ b/client/src/ui/Toast.stories.tsx
@@ -0,0 +1,140 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { ToastProvider, useToast } from './Toast';
+import type { ToastProviderProps } from './Toast';
+import Button from './Button';
+
+const meta: Meta = {
+ title: 'UI/Toast',
+ component: ToastProvider,
+ tags: ['autodocs'],
+ parameters: {
+ controls: { expanded: true },
+ docs: { source: { type: 'dynamic' } },
+ },
+ argTypes: {
+ max: { control: { type: 'number', min: 1, max: 10 } },
+ dense: { control: 'boolean' },
+ pauseOnHover: { control: 'boolean' },
+ anchorOrigin: {
+ control: 'object',
+ description: 'Position of the toast stack',
+ },
+ classes: {
+ control: 'object',
+ description: 'Tailwind classes for container and items',
+ },
+ },
+ args: {
+ max: 4,
+ dense: false,
+ pauseOnHover: true,
+ anchorOrigin: { vertical: 'top', horizontal: 'right' },
+ classes: {
+ container: '',
+ item: '',
+ icon: '',
+ message: '',
+ action: '',
+ },
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+const Demo: React.FC = () => {
+ const toast = useToast();
+ return (
+
+
+
Variants
+
toast.success('Success')}>Success
+
toast.error('Error')}>Error
+
toast.warning('Warning')}>Warning
+
toast.info('Info')}>Info
+
+
+
Loading → Success
+
{
+ const id = toast.loading('Loading...');
+ setTimeout(() => toast.success('Done', { replaceId: id }), 1000);
+ }}>Trigger
+
+
+ );
+};
+
+export const Playground: Story = {
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const WithCustomClasses: Story = {
+ name: 'With custom classes',
+ args: {
+ classes: {
+ container: 'gap-4',
+ item: 'ring-2 ring-offset-2',
+ icon: 'opacity-90',
+ message: 'font-medium',
+ action: 'underline',
+ },
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const StaticExample: Story = {
+ name: 'Static example (copy-paste)',
+ parameters: {
+ docs: {
+ source: {
+ code: `import { ToastProvider } from './Toast';
+
+export default function Page() {
+ return (
+
+ {/* ...your app... */}
+
+ );
+}`,
+ },
+ },
+ },
+ render: () => (
+
+
+
+ ),
+};
diff --git a/client/src/ui/Toast.tsx b/client/src/ui/Toast.tsx
new file mode 100644
index 0000000..0518b95
--- /dev/null
+++ b/client/src/ui/Toast.tsx
@@ -0,0 +1,237 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+ useEffect,
+} from 'react';
+import type { PropsWithChildren } from 'react';
+import { SeverityStyles } from './constants/toast';
+import { SuccessIcon, ErrorIcon, WarningIcon, InfoIcon, LoadingIcon } from './icons/ToastIcons';
+
+// Types
+export type ToastSeverity = 'success' | 'error' | 'warning' | 'info' | 'loading';
+export type ToastVariant = 'filled' | 'outlined' | 'standard';
+
+export interface ToastOptions {
+ severity?: ToastSeverity;
+ variant?: ToastVariant;
+ autoHideDuration?: number | null;
+ action?: React.ReactNode;
+ replaceId?: string;
+}
+
+export interface ToastItem extends Required {
+ id: string;
+ message: React.ReactNode;
+}
+
+export interface AnchorOrigin {
+ vertical: 'top' | 'bottom';
+ horizontal: 'left' | 'center' | 'right';
+}
+
+export interface ToastProviderProps {
+ max?: number;
+ anchorOrigin?: AnchorOrigin;
+ dense?: boolean;
+ pauseOnHover?: boolean;
+ classes?: {
+ container?: string;
+ item?: string;
+ icon?: string;
+ message?: string;
+ action?: string;
+ };
+}
+
+export interface ToastContextType {
+ enqueue: (message: React.ReactNode, options?: ToastOptions) => string;
+ close: (id: string) => void;
+ success: (message: React.ReactNode, options?: Omit) => string;
+ error: (message: React.ReactNode, options?: Omit) => string;
+ warning: (message: React.ReactNode, options?: Omit) => string;
+ info: (message: React.ReactNode, options?: Omit) => string;
+ loading: (message: React.ReactNode, options?: Omit) => string;
+}
+
+const ToastContext = createContext(null);
+
+export const useToast = (): ToastContextType => {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error('useToast must be used within a ToastProvider');
+ return ctx;
+};
+
+// Toast visual component
+export interface ToastProps {
+ item: ToastItem;
+ onClose: (id: string) => void;
+ paused?: boolean;
+ classes?: {
+ root?: string;
+ icon?: string;
+ message?: string;
+ action?: string;
+ };
+}
+
+const severityStyles: Record = SeverityStyles
+const Icons: Record = {
+ success: ,
+ error: ,
+ warning: ,
+ info: ,
+ loading: ,
+};
+
+const Toast: React.FC = ({ item, onClose, paused = false, classes }) => {
+ const { id, message, severity, variant, action, autoHideDuration } = item;
+
+ useEffect(() => {
+ if (autoHideDuration === null || paused) return;
+ const t = window.setTimeout(() => onClose(id), autoHideDuration);
+ return () => window.clearTimeout(t);
+ }, [id, autoHideDuration, onClose, paused]);
+
+ const styles = severityStyles[severity][variant];
+
+ return (
+ onClose(id)}
+ role="alert"
+ className={[
+ 'pointer-events-auto flex items-center gap-3 rounded-md shadow-lg px-4 py-3',
+ styles,
+ classes?.root,
+ ].filter(Boolean).join(' ')}
+ >
+
{Icons[severity]}
+
{message}
+ {action &&
{action}
}
+
+ );
+};
+
+// Container renders a stack based on anchor origin
+export const ToastContainer: React.FC<{
+ toasts: ToastItem[];
+ onClose: (id: string) => void;
+ anchorOrigin: AnchorOrigin;
+ dense?: boolean;
+ pauseOnHover?: boolean;
+ classes?: ToastProviderProps['classes'];
+}> = ({ toasts, onClose, anchorOrigin, dense, pauseOnHover, classes }) => {
+ const position = useMemo(() => {
+ const v = anchorOrigin.vertical === 'top' ? 'top-4' : 'bottom-4';
+ const h =
+ anchorOrigin.horizontal === 'left'
+ ? 'left-4'
+ : anchorOrigin.horizontal === 'right'
+ ? 'right-4'
+ : 'left-1/2 -translate-x-1/2';
+ return `${v} ${h}`;
+ }, [anchorOrigin]);
+
+ const [paused, setPaused] = useState(false);
+
+ return (
+ setPaused(true) : undefined}
+ onMouseLeave={pauseOnHover ? () => setPaused(false) : undefined}
+ >
+ {toasts.map((t) => (
+
+
+
+ ))}
+
+ );
+};
+
+// Provider manages queue and exposes API
+export const ToastProvider: React.FC> = ({
+ children,
+ max = 4,
+ anchorOrigin = { vertical: 'top', horizontal: 'right' },
+ dense = false,
+ pauseOnHover = true,
+ classes,
+}) => {
+ const [toasts, setToasts] = useState([]);
+ const counter = useRef(0);
+
+ const close = useCallback((id: string) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+
+ const enqueue = useCallback((message, options) => {
+ const id = options?.replaceId ?? `t_${Date.now()}_${counter.current++}`;
+ const defaultDuration = options?.severity === 'loading' ? null : 3000;
+ setToasts((prev) => {
+ const base: ToastItem = {
+ id,
+ message,
+ severity: options?.severity ?? 'info',
+ variant: options?.variant ?? 'filled',
+ autoHideDuration:
+ options?.autoHideDuration === undefined ? defaultDuration : options.autoHideDuration,
+ action: options?.action ?? null,
+ replaceId: options?.replaceId ?? id,
+ } as ToastItem;
+
+ // Replace existing
+ const next = prev.filter((t) => t.id !== options?.replaceId);
+ next.unshift(base);
+ return next.slice(0, max);
+ });
+ return id;
+ }, [max]);
+
+ const api = useMemo(() => ({
+ enqueue,
+ close,
+ success: (m, o) => enqueue(m, { ...o, severity: 'success' }),
+ error: (m, o) => enqueue(m, { ...o, severity: 'error' }),
+ warning: (m, o) => enqueue(m, { ...o, severity: 'warning' }),
+ info: (m, o) => enqueue(m, { ...o, severity: 'info' }),
+ loading: (m, o) => enqueue(m, { ...o, severity: 'loading', autoHideDuration: null }),
+ }), [enqueue, close]);
+
+ // Portal to body to avoid clipping
+ const portal = (
+
+ );
+
+ return (
+
+ {children}
+ {portal}
+
+ );
+};
+
+export default Toast;
+
diff --git a/client/src/ui/Tooltip.stories.tsx b/client/src/ui/Tooltip.stories.tsx
new file mode 100644
index 0000000..7ff4da8
--- /dev/null
+++ b/client/src/ui/Tooltip.stories.tsx
@@ -0,0 +1,135 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import Tooltip from './Tooltip';
+import Button from './Button';
+
+const meta: Meta = {
+ title: 'UI/Tooltip',
+ component: Tooltip,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ source: { type: 'dynamic' },
+ description: {
+ component:
+ 'Tooltip simple basé sur un enfant unique. Ouvre au survol, focus clavier ou touch (configurable).\n' +
+ '\n' +
+ '• Positionnement via hook partagé useAnchoredPopover (flip + clamp).\n' +
+ '• Rendu via portal dans document.body.\n' +
+ '• Invisible jusqu\'à ce que la position soit prête, puis fade rapide.\n' +
+ '• API stable : title, placement, arrow, enterDelay, leaveDelay, disable*Listener, className, classes.{content,arrow}.',
+ },
+ },
+ },
+ args: { title: 'Info', placement: 'top' },
+};
+export default meta;
+
+type Story = StoryObj;
+
+const Template = (args: React.ComponentProps) => (
+
+
+ Hover me
+
+
+);
+
+export const Top: Story = { render: (args) => };
+export const Bottom: Story = { args: { placement: 'bottom' }, render: (args) => };
+export const Left: Story = { args: { placement: 'left' }, render: (args) => };
+export const Right: Story = { args: {
+ placement: 'right',
+ classes: {},
+ className: "bg-red-500"
+}, render: (args) => };
+export const NoArrow: Story = { args: { arrow: false }, render: (args) => };
+
+export const StaticExample: Story = {
+ name: 'Static example (copy-paste)',
+ parameters: {
+ docs: {
+ source: {
+ code: `import Tooltip from './Tooltip';
+import Button from './Button';
+
+export default function Page() {
+ return (
+
+
+ Hover me
+
+
+ Hover me
+
+
+ Hover me
+
+
+ Hover me
+
+
+ );
+}`,
+ },
+ },
+ },
+ render: () => (
+
+
+ Hover me
+
+
+ Hover me
+
+
+ Hover me
+
+
+ Hover me
+
+
+ ),
+};
+
+export const AllPlacements: Story = {
+ name: 'All placements grid',
+ render: () => (
+
+ Top
+ Right
+ Bottom
+ Left
+
+ ),
+};
+
+export const Delays: Story = {
+ args: { enterDelay: 80, leaveDelay: 60 },
+ render: (args) => (
+
+ Hover me (delays)
+
+ ),
+};
+
+export const DisabledListeners: Story = {
+ args: { disableHoverListener: true, disableTouchListener: true },
+ render: (args) => (
+
+
Focus me (keyboard)
+
Hover/touch désactivés, utiliser Tab/Shift+Tab pour focus.
+
+ ),
+};
+
+export const CustomClasses: Story = {
+ name: 'Custom classes (content/arrow)',
+ args: {
+ classes: { content: 'bg-indigo-600 text-white', arrow: 'bg-indigo-600' },
+ },
+ render: (args) => (
+
+ Hover me
+
+ ),
+};
diff --git a/client/src/ui/Tooltip.tsx b/client/src/ui/Tooltip.tsx
new file mode 100644
index 0000000..2e1446a
--- /dev/null
+++ b/client/src/ui/Tooltip.tsx
@@ -0,0 +1,155 @@
+import React, { cloneElement, isValidElement, useEffect, useMemo, useRef, useState, useId } from 'react';
+import { createPortal } from 'react-dom';
+import { useAnchoredPopover } from './hooks/useAnchoredPopover';
+import { TOOLTIP_BASE, TOOLTIP_HIDDEN, TOOLTIP_SHOWN, arrowPlacementClass, initialMotionClass, type TooltipPlacement } from './constants/tooltip';
+
+export type { TooltipPlacement };
+
+export interface TooltipProps {
+ children: React.ReactElement;
+ title: React.ReactNode;
+ placement?: TooltipPlacement;
+ arrow?: boolean;
+ className?: string;
+ enterDelay?: number;
+ leaveDelay?: number;
+ disableHoverListener?: boolean;
+ disableFocusListener?: boolean;
+ disableTouchListener?: boolean;
+ classes?: {
+ content?: string;
+ arrow?: string;
+ };
+}
+
+const Tooltip: React.FC = ({
+ children,
+ title,
+ placement = 'top',
+ arrow = true,
+ className = '',
+ enterDelay = 100,
+ leaveDelay = 0,
+ disableHoverListener = false,
+ disableFocusListener = false,
+ disableTouchListener = true,
+ classes,
+}) => {
+ const [open, setOpen] = useState(false);
+ const [ready, setReady] = useState(false);
+ const childRef = useRef(null);
+ const tooltipRef = useRef(null);
+ const enterTimer = useRef | null>(null);
+ const leaveTimer = useRef | null>(null);
+ const { coords, placement: computedPlacement, updatePosition } = useAnchoredPopover(open, childRef, tooltipRef, [placement, arrow], placement);
+ const tooltipId = useId();
+
+ const clearTimers = () => {
+ if (enterTimer.current) {
+ clearTimeout(enterTimer.current);
+ enterTimer.current = null;
+ }
+ if (leaveTimer.current) {
+ clearTimeout(leaveTimer.current);
+ leaveTimer.current = null;
+ }
+ };
+
+ const show = () => {
+ if (!title) return;
+ clearTimers();
+ setReady(false);
+ enterTimer.current = setTimeout(() => setOpen(true), enterDelay);
+ };
+
+ const hide = () => {
+ clearTimers();
+ setReady(false);
+ leaveTimer.current = setTimeout(() => setOpen(false), leaveDelay);
+ };
+
+ useEffect(() => {
+ if (!open) return;
+ // Keep hidden until positioned, then reveal next frame
+ setReady(false);
+ requestAnimationFrame(() => {
+ updatePosition();
+ requestAnimationFrame(() => setReady(true));
+ });
+ }, [open, updatePosition]);
+
+ useEffect(() => () => clearTimers(), []);
+
+ const arrowClass = useMemo(() => arrowPlacementClass(computedPlacement), [computedPlacement]);
+
+ const motionBase = useMemo(() => initialMotionClass(computedPlacement), [computedPlacement]);
+
+ const tooltipClasses = useMemo(() => {
+ const base = TOOLTIP_BASE;
+ const hidden = TOOLTIP_HIDDEN;
+ const shown = TOOLTIP_SHOWN;
+ // Start slightly offset, settle to zero when shown
+ const motion = open && ready ? 'translate-x-0 translate-y-0' : motionBase;
+ return [base, open && ready ? shown : hidden, motion, className, classes?.content].filter(Boolean).join(' ');
+ }, [open, ready, motionBase, className, classes?.content]);
+
+ if (!isValidElement(children)) return children;
+
+ // Safely capture original handlers to preserve them without TS complaining about {}
+ const originalProps: any = (children as any).props || {};
+
+ const childWithHandlers = cloneElement(children, {
+ ref: childRef,
+ 'aria-describedby': open && title
+ ? [originalProps['aria-describedby'], tooltipId].filter(Boolean).join(' ')
+ : originalProps['aria-describedby'],
+ onMouseEnter: (e: React.MouseEvent) => {
+ if (!disableHoverListener) show();
+ originalProps.onMouseEnter?.(e);
+ },
+ onMouseLeave: (e: React.MouseEvent) => {
+ if (!disableHoverListener) hide();
+ originalProps.onMouseLeave?.(e);
+ },
+ onFocus: (e: React.FocusEvent) => {
+ if (!disableFocusListener) show();
+ originalProps.onFocus?.(e);
+ },
+ onBlur: (e: React.FocusEvent) => {
+ if (!disableFocusListener) hide();
+ originalProps.onBlur?.(e);
+ },
+ onTouchStart: (e: React.TouchEvent) => {
+ if (!disableTouchListener) show();
+ originalProps.onTouchStart?.(e);
+ },
+ onTouchEnd: (e: React.TouchEvent) => {
+ if (!disableTouchListener) hide();
+ originalProps.onTouchEnd?.(e);
+ },
+ } as any);
+
+ return (
+ <>
+ {childWithHandlers}
+ {open && title && createPortal(
+ ,
+ document.body
+ )}
+ >
+ );
+};
+
+export default Tooltip;
+
diff --git a/client/src/ui/constants/date.ts b/client/src/ui/constants/date.ts
new file mode 100644
index 0000000..d4e6155
--- /dev/null
+++ b/client/src/ui/constants/date.ts
@@ -0,0 +1,33 @@
+// Shared date-related constants and utilities
+
+export const WEEKDAYS: string[] = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
+
+export function startOfDay(d: Date) {
+ const x = new Date(d);
+ x.setHours(0, 0, 0, 0);
+ return x;
+}
+
+export function isSameDay(a: Date, b: Date) {
+ return startOfDay(a).getTime() === startOfDay(b).getTime();
+}
+
+export function clamp(date: Date, min?: Date, max?: Date) {
+ let d = date;
+ if (min && d < min) d = min;
+ if (max && d > max) d = max;
+ return d;
+}
+
+export function getMonthDays(view: Date): (Date | null)[] {
+ const monthStart = new Date(view);
+ monthStart.setDate(1);
+ const monthEnd = new Date(view);
+ monthEnd.setMonth(monthEnd.getMonth() + 1, 0);
+ const leading = (monthStart.getDay() + 6) % 7; // Monday=0
+ const totalDays = monthEnd.getDate();
+ const out: (Date | null)[] = [];
+ for (let i = 0; i < leading; i++) out.push(null);
+ for (let d = 1; d <= totalDays; d++) out.push(new Date(view.getFullYear(), view.getMonth(), d));
+ return out;
+}
diff --git a/client/src/ui/constants/datepicker.ts b/client/src/ui/constants/datepicker.ts
new file mode 100644
index 0000000..60e5cae
--- /dev/null
+++ b/client/src/ui/constants/datepicker.ts
@@ -0,0 +1,18 @@
+// Shared DatePicker class constants (keep Tailwind classes literal)
+
+export const CALENDAR_BASE = (
+ 'fixed z-[1002] rounded-md border bg-white p-2 shadow-lg ring-1 ring-gray-200 min-w-[14rem]'
+);
+
+export const CALENDAR_ANIM_TOP = 'origin-bottom animate-[fadeUp_120ms_ease-out]';
+export const CALENDAR_ANIM_BOTTOM = 'origin-top animate-[fadeDown_120ms_ease-out]';
+
+export const NAV_BASE = 'mb-2 flex items-center justify-between';
+export const GRID_BASE = 'grid grid-cols-7 gap-1';
+
+export const HEADER_ROW = 'grid grid-cols-7 gap-1 text-[11px] text-gray-500 pb-1';
+export const HEADER_CELL = 'grid place-items-center h-6 w-8';
+
+export const DAY_BASE = 'h-8 w-8 grid place-items-center rounded-md text-sm cursor-pointer p-0 leading-none';
+export const DAY_SELECTED = 'bg-sky-600 text-white';
+export const DAY_DISABLED = 'opacity-40 cursor-not-allowed';
diff --git a/client/src/ui/constants/inputs.ts b/client/src/ui/constants/inputs.ts
new file mode 100644
index 0000000..9385a87
--- /dev/null
+++ b/client/src/ui/constants/inputs.ts
@@ -0,0 +1,36 @@
+// Shared UI input constants (Tailwind literal classes only)
+
+export type InputSize = 'small' | 'medium' | 'large';
+
+// Heights are approximate based on padding + line-height:
+// - large ≈56px (previous default for inputs)
+// - medium ≈52px (new default)
+// - small ≈48px
+export const SIZE_PADDING_MAP: Record = {
+ large: 'px-4 py-3.5 text-base',
+ medium: 'px-4 py-3 text-base',
+ small: 'px-4 py-2.5 text-sm',
+};
+
+// Floating label base used by inputs that implement a Material-like label.
+// Keep as a literal string so Tailwind can see all classes.
+export const LABEL_BASE = (
+ `pointer-events-none absolute z-10 transition-all duration-150
+ -top-1 translate-y-0 text-xs text-gray-700 font-medium px-1
+ peer-focus:-top-1 peer-focus:text-xs peer-focus:text-sky-700 peer-focus:translate-y-0 peer-focus:px-1
+ peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:text-base
+ peer-placeholder-shown:text-gray-500 peer-placeholder-shown:font-normal peer-placeholder-shown:px-0`
+).trim().replace(/\s+/g, ' ');
+
+// Common input base classes that many inputs can share (optional).
+export const INPUT_BASE = (
+ 'peer block w-full rounded-md border'
+);
+
+export const INPUT_RING_BASE = (
+ 'bg-white text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300'
+);
+
+export const INPUT_FOCUS_BASE = (
+ 'focus:outline-none focus:ring-2 focus:ring-sky-500'
+);
diff --git a/client/src/ui/constants/textfield.ts b/client/src/ui/constants/textfield.ts
new file mode 100644
index 0000000..fc95bb4
--- /dev/null
+++ b/client/src/ui/constants/textfield.ts
@@ -0,0 +1,29 @@
+// Shared constants for TextField styling (Tailwind classes kept literal)
+
+export const TEXTFIELD_BASE_INTERACTIVE = (
+ 'peer w-full rounded-md placeholder-transparent text-gray-900 focus:outline-none transition-[box-shadow,border-color,background-color] duration-150 ease-out'
+);
+
+export const TEXTFIELD_OUTLINED = (
+ 'bg-white ring-1 ring-inset hover:ring-gray-400 shadow-sm border'
+);
+export const TEXTFIELD_OUTLINED_OK = 'ring-gray-300 border-gray-300 focus:ring-2 focus:ring-sky-500';
+export const TEXTFIELD_OUTLINED_ERR = 'ring-red-300 border-red-300 focus:ring-2 focus:ring-sky-500';
+
+export const TEXTFIELD_FILLED = (
+ 'bg-gray-50 ring-1 ring-inset hover:bg-gray-100 shadow-inner border'
+);
+export const TEXTFIELD_FILLED_OK = 'ring-gray-300 border-transparent focus:ring-2 focus:ring-sky-500';
+export const TEXTFIELD_FILLED_ERR = 'ring-red-300 border-transparent focus:ring-2 focus:ring-sky-500';
+
+export const TEXTFIELD_STANDARD = 'bg-transparent border-0 border-b rounded-none';
+export const TEXTFIELD_STANDARD_OK = 'border-gray-300 focus:ring-0 focus:border-sky-500';
+export const TEXTFIELD_STANDARD_ERR = 'border-red-300 focus:ring-0 focus:border-sky-500';
+
+export function labelBgByVariant(variant: 'outlined'|'filled'|'standard') {
+ return variant === 'filled' ? 'bg-gray-50' : variant === 'standard' ? 'bg-transparent' : 'bg-white';
+}
+
+export function labelFocusBgByVariant(variant: 'outlined'|'filled'|'standard') {
+ return variant === 'filled' ? 'peer-focus:bg-gray-50' : variant === 'standard' ? 'peer-focus:bg-transparent' : 'peer-focus:bg-white';
+}
diff --git a/client/src/ui/constants/toast.ts b/client/src/ui/constants/toast.ts
new file mode 100644
index 0000000..d9b68e0
--- /dev/null
+++ b/client/src/ui/constants/toast.ts
@@ -0,0 +1,27 @@
+export const SeverityStyles = {
+ success: {
+ filled: 'bg-green-600 text-white border-green-600',
+ outlined: 'bg-transparent text-green-700 border border-green-300',
+ standard: 'bg-green-50 text-green-800 border border-green-100',
+ },
+ error: {
+ filled: 'bg-red-600 text-white border-red-600',
+ outlined: 'bg-transparent text-red-700 border border-red-300',
+ standard: 'bg-red-50 text-red-800 border border-red-100',
+ },
+ warning: {
+ filled: 'bg-amber-600 text-white border-amber-600',
+ outlined: 'bg-transparent text-amber-800 border border-amber-300',
+ standard: 'bg-amber-50 text-amber-900 border border-amber-100',
+ },
+ info: {
+ filled: 'bg-sky-600 text-white border-sky-600',
+ outlined: 'bg-transparent text-sky-800 border border-sky-300',
+ standard: 'bg-sky-50 text-sky-900 border border-sky-100',
+ },
+ loading: {
+ filled: 'bg-gray-700 text-white border-gray-700',
+ outlined: 'bg-transparent text-gray-800 border border-gray-300',
+ standard: 'bg-gray-50 text-gray-900 border border-gray-100',
+ },
+} as const;
diff --git a/client/src/ui/constants/tooltip.ts b/client/src/ui/constants/tooltip.ts
new file mode 100644
index 0000000..cac4aec
--- /dev/null
+++ b/client/src/ui/constants/tooltip.ts
@@ -0,0 +1,33 @@
+// Tooltip shared style constants and small helpers (keep Tailwind classes literal)
+
+export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
+
+export const TOOLTIP_BASE = (
+ `fixed z-[9998] max-w-xs break-words select-none
+ px-2.5 py-1.5 text-xs rounded-md shadow-lg
+ bg-gray-900 text-white
+ transition-opacity transition-transform duration-120 ease-out
+ will-change-transform will-change-opacity`
+).trim().replace(/\s+/g, ' ');
+
+export const TOOLTIP_HIDDEN = 'opacity-0 invisible pointer-events-none';
+export const TOOLTIP_SHOWN = 'opacity-100 visible';
+
+export function arrowPlacementClass(placement: TooltipPlacement) {
+ const b = 'absolute w-2 h-2 rotate-45';
+ switch (placement) {
+ case 'top': return `${b} -bottom-1 left-1/2 -translate-x-1/2`;
+ case 'bottom': return `${b} -top-1 left-1/2 -translate-x-1/2`;
+ case 'left': return `${b} -right-1 top-1/2 -translate-y-1/2`;
+ case 'right': return `${b} -left-1 top-1/2 -translate-y-1/2`;
+ }
+}
+
+export function initialMotionClass(placement: TooltipPlacement) {
+ switch (placement) {
+ case 'top': return 'translate-y-1';
+ case 'bottom': return '-translate-y-1';
+ case 'left': return 'translate-x-1';
+ case 'right': return '-translate-x-1';
+ }
+}
diff --git a/client/src/ui/hooks/useAnchoredPopover.ts b/client/src/ui/hooks/useAnchoredPopover.ts
new file mode 100644
index 0000000..ea53d33
--- /dev/null
+++ b/client/src/ui/hooks/useAnchoredPopover.ts
@@ -0,0 +1,99 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+
+export type PopoverPlacement = 'bottom' | 'top' | 'left' | 'right';
+
+type AnyRef = React.RefObject | React.MutableRefObject;
+
+export function useAnchoredPopover(
+ open: boolean,
+ anchorRef: AnyRef,
+ popoverRef: AnyRef,
+ deps: unknown[] = [],
+ preferred?: PopoverPlacement,
+) {
+ const [coords, setCoords] = useState<{ left: number; top: number; width: number }>({ left: 0, top: 0, width: 0 });
+ const [placement, setPlacement] = useState('bottom');
+ const rafRef = useRef(null);
+ const depsKey = JSON.stringify(deps);
+
+ const updatePosition = useCallback(() => {
+ const anchor = anchorRef.current; if (!anchor) return;
+ const pop = popoverRef.current;
+ const a = anchor.getBoundingClientRect();
+ const vw = window.innerWidth; const vh = window.innerHeight;
+ const margin = 8; // viewport margin
+ const gap = 6; // base gap; caller may add arrow visually
+
+ // Measure popover if available
+ const tW = pop ? pop.offsetWidth : 0;
+ const tH = pop ? pop.offsetHeight : 0;
+
+ let pl: PopoverPlacement = preferred ?? 'bottom';
+ let left = a.left;
+ let top = a.bottom + gap;
+
+ const placeBottom = () => { left = a.left + a.width / 2 - tW / 2; top = a.bottom + gap; pl = 'bottom'; };
+ const placeTop = () => { left = a.left + a.width / 2 - tW / 2; top = a.top - tH - gap; pl = 'top'; };
+ const placeLeft = () => { left = a.left - tW - gap; top = a.top + a.height / 2 - tH / 2; pl = 'left'; };
+ const placeRight = () => { left = a.right + gap; top = a.top + a.height / 2 - tH / 2; pl = 'right'; };
+
+ // initial
+ if (pl === 'top') placeTop();
+ else if (pl === 'left') placeLeft();
+ else if (pl === 'right') placeRight();
+ else placeBottom();
+
+ // flip logic
+ if (pop) {
+ if (pl === 'bottom' && top + tH + margin > vh) placeTop();
+ else if (pl === 'top' && top < margin) placeBottom();
+ else if (pl === 'left' && left < margin) placeRight();
+ else if (pl === 'right' && left + tW + margin > vw) placeLeft();
+ }
+
+ // clamp within viewport
+ if (pop) {
+ if (pl === 'top' || pl === 'bottom') {
+ // horizontally clamp
+ left = Math.max(margin, Math.min(left, vw - tW - margin));
+ } else {
+ // vertically clamp
+ top = Math.max(margin, Math.min(top, vh - tH - margin));
+ }
+ } else {
+ // Without pop size, at least clamp anchor horizontally
+ left = Math.max(margin, Math.min(left, vw - a.width - margin));
+ }
+
+ setPlacement(pl);
+ setCoords({ left, top, width: a.width });
+ }, [anchorRef, popoverRef, preferred]);
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ updatePosition();
+ }, [open, updatePosition, depsKey]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onResize = () => {
+ if (rafRef.current) cancelAnimationFrame(rafRef.current!);
+ rafRef.current = requestAnimationFrame(updatePosition);
+ };
+ const onScroll = () => {
+ if (rafRef.current) cancelAnimationFrame(rafRef.current!);
+ rafRef.current = requestAnimationFrame(updatePosition);
+ };
+ window.addEventListener('resize', onResize);
+ window.addEventListener('scroll', onScroll, true);
+ const ro = new ResizeObserver(() => onResize());
+ if (anchorRef.current) ro.observe(anchorRef.current);
+ return () => {
+ window.removeEventListener('resize', onResize);
+ window.removeEventListener('scroll', onScroll, true);
+ ro.disconnect();
+ };
+ }, [open, updatePosition, anchorRef]);
+
+ return { coords, placement, updatePosition } as const;
+}
diff --git a/client/src/ui/icons/ToastIcons.tsx b/client/src/ui/icons/ToastIcons.tsx
new file mode 100644
index 0000000..d6c88ea
--- /dev/null
+++ b/client/src/ui/icons/ToastIcons.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+
+export const SuccessIcon: React.FC = () => (
+
+
+
+
+
+
+
+
+);
+
+export const ErrorIcon: React.FC = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const WarningIcon: React.FC = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const InfoIcon: React.FC = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const LoadingIcon: React.FC = () => (
+
+ {Array.from({ length: 12 }).map((_, i) => {
+ const angle = (i * 30);
+ const opacity = 1 - i * 0.08;
+ return (
+
+ );
+ })}
+
+);
diff --git a/client/src/ui/index.ts b/client/src/ui/index.ts
new file mode 100644
index 0000000..a9858f0
--- /dev/null
+++ b/client/src/ui/index.ts
@@ -0,0 +1,22 @@
+export { default as Button } from './Button';
+export { default as Tooltip } from './Tooltip';
+export { default as Chip } from './Chip';
+export { default as Toast, ToastProvider, ToastContainer, useToast } from './Toast';
+export { default as IconButton } from './IconButton';
+export { default as Dialog } from './Dialog';
+export { default as Skeleton } from './Skeleton';
+export { default as Select } from './Select';
+export { default as DatePicker } from './DatePicker';
+export { default as TextField } from './TextField';
+
+
+export type { ButtonProps } from './Button';
+export type { TooltipProps } from './Tooltip';
+export type { ToastProps, ToastContextType } from './Toast';
+export type { ChipProps } from './Chip';
+export type { IconButtonProps } from './IconButton';
+export type { DialogProps } from './Dialog';
+export type { SkeletonProps } from './Skeleton';
+export type { SelectProps } from './Select';
+export type { DatePickerProps } from './DatePicker';
+export type { TextFieldProps } from './TextField';
diff --git a/server/.vercel/README.txt b/server/.vercel/README.txt
new file mode 100644
index 0000000..525d8ce
--- /dev/null
+++ b/server/.vercel/README.txt
@@ -0,0 +1,11 @@
+> Why do I have a folder named ".vercel" in my project?
+The ".vercel" folder is created when you link a directory to a Vercel project.
+
+> What does the "project.json" file contain?
+The "project.json" file contains:
+- The ID of the Vercel project that you linked ("projectId")
+- The ID of the user or team your Vercel project is owned by ("orgId")
+
+> Should I commit the ".vercel" folder?
+No, you should not share the ".vercel" folder with anyone.
+Upon creation, it will be automatically added to your ".gitignore" file.
diff --git a/server/.vercel/project.json b/server/.vercel/project.json
new file mode 100644
index 0000000..bd5a154
--- /dev/null
+++ b/server/.vercel/project.json
@@ -0,0 +1 @@
+{"projectId":"prj_WtGs8rY0LQMAb5NSjBhyC7oEf7aC","orgId":"team_SGYCNpXTJXSnJsUUxVebGrd1","projectName":"only-dev-expense-tracker-server"}
\ No newline at end of file
diff --git a/server/README.md b/server/README.md
index 0f4d780..77209e4 100644
--- a/server/README.md
+++ b/server/README.md
@@ -1,42 +1,64 @@
-# Expense Tracker Server
+
+
Expense Tracker – Server
+
Express API powering the Expense Tracker.
+
+
+
+
+
+
-Express.js backend server for the Expense Tracker application.
+Tech Stack
-## 🚀 Quick Start
+- Node.js 18+
+- Express 4
+- PostgreSQL (via `pg`)
+- Helmet, CORS, Morgan
-### Prerequisites
-- Node.js (v18 or higher)
+Requirements
+
+- Node.js >= 18
- npm or yarn
-### Installation
+Installation & Run
+
+1) Install dependencies
+
+```bash
+npm install
+```
+
+2) Configure environment
+
+```bash
+cp .env.example .env
+```
+
+Then edit `.env` with your actual configuration values.
+
+3) Start the server
+
+```bash
+# Development (auto-reload)
+npm run dev
-1. **Install dependencies**
- ```bash
- npm install
- ```
+# Production
+npm start
+```
-2. **Setup environment variables**
- ```bash
- cp .env.example .env
- ```
- Then edit `.env` with your actual configuration values.
+Default: http://localhost:8080
-3. **Start the server**
- ```bash
- # Development mode (with auto-reload)
- npm run dev
-
- # Production mode
- npm start
- ```
+Environment
-The server will start on `http://localhost:8080` by default.
+Key variables in `server/.env`:
-## 🤝 Development
+- `PORT` – server port (default: 8080)
+- `DATABASE_URL` – Postgres connection string
+- `JWT_SECRET` – secret for JWT signing
+- `CORS_ORIGIN` – allowed frontend origin (e.g., http://localhost:5173)
-For other developers joining the project:
+Notes
-1. Clone the repository
-2. Follow the installation steps above
-3. Make sure to use the `.env.example` as a template
-4. Contact the team for any missing environment variables
+- Logging via `morgan` (dev-friendly).
+- Security headers via `helmet`.
+- CORS configured via `CORS_ORIGIN`.
diff --git a/server/vercel.json b/server/vercel.json
new file mode 100644
index 0000000..aa4adf3
--- /dev/null
+++ b/server/vercel.json
@@ -0,0 +1,18 @@
+{
+ "version": 2,
+ "builds": [
+ {
+ "src": "server.js",
+ "use": "@vercel/node"
+ }
+ ],
+ "routes": [
+ {
+ "src": "/(.*)",
+ "dest": "/server.js"
+ }
+ ],
+ "env": {
+ "NODE_ENV": "production"
+ }
+}