diff --git a/.changeset/bright-owls-approve.md b/.changeset/bright-owls-approve.md new file mode 100644 index 0000000..9bad333 --- /dev/null +++ b/.changeset/bright-owls-approve.md @@ -0,0 +1,27 @@ +--- +'@ciscode/ui-notification-kit': minor +--- + +Ship the first functional release of NotificationKit-UI. + +### Added + +- Notification provider and hook API (`NotificationProvider`, `useNotification`) +- Notification types: success, error, warning, info, loading, default +- Position support: top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right +- Configurable animations (slide, fade, scale), durations, auto-dismiss, close button, actions, and custom icons +- Store lifecycle with add/update/dismiss/clear/restore and history tracking +- Route-aware clearing support (`clearOnNavigate`, `navigationKey`) +- Accessibility support (live-region announcements, ARIA roles, keyboard escape-to-dismiss) +- Tailwind + RTL styling support and published style asset export (`./style.css`) +- Test coverage for store behavior, provider behavior, and a11y essentials + +### Changed + +- Package entry exports updated to align with generated build outputs +- Notification rendering moved to a portal to avoid stacking-context issues in host apps +- Layering hardened so notifications stay above dashboard content + +### Notes + +- Import styles in host apps using: `@ciscode/ui-notification-kit/style.css` diff --git a/.github/instructions/bugfix.instructions.md b/.github/instructions/bugfix.instructions.md index 143391f..9c7da05 100644 --- a/.github/instructions/bugfix.instructions.md +++ b/.github/instructions/bugfix.instructions.md @@ -21,7 +21,7 @@ describe('Bug: Button not disabled when loading', () => { it('should disable button during loading', () => { render(); - + // This SHOULD pass but currently FAILS expect(screen.getByRole('button')).toBeDisabled(); }); @@ -59,11 +59,11 @@ useEffect(() => { ### 1. State Management Issues -| Bug Type | Symptoms | Solution | -| --------------------- | ---------------------- | --------------------------- | -| **Stale closure** | Old values in callback | Update dependencies | -| **Infinite loop** | Component re-renders | Fix useEffect dependencies | -| **Lost state** | State resets unexpectedly| Check component key | +| Bug Type | Symptoms | Solution | +| ----------------- | ------------------------- | -------------------------- | +| **Stale closure** | Old values in callback | Update dependencies | +| **Infinite loop** | Component re-renders | Fix useEffect dependencies | +| **Lost state** | State resets unexpectedly | Check component key | **Example fix:** @@ -81,7 +81,7 @@ useEffect(() => { // ✅ FIX - Functional update useEffect(() => { const timer = setInterval(() => { - setCount(prev => prev + 1); // ✅ Uses current count + setCount((prev) => prev + 1); // ✅ Uses current count }, 1000); return () => clearInterval(timer); }, []); @@ -89,11 +89,11 @@ useEffect(() => { ### 2. useEffect Issues -| Bug Type | Symptoms | Solution | -| --------------------- | --------------------- | --------------------------- | -| **Memory leak** | Performance degrades | Add cleanup function | -| **Missing cleanup** | Side effects persist | Return cleanup | -| **Wrong dependencies**| Unexpected behavior | Fix dependency array | +| Bug Type | Symptoms | Solution | +| ---------------------- | -------------------- | -------------------- | +| **Memory leak** | Performance degrades | Add cleanup function | +| **Missing cleanup** | Side effects persist | Return cleanup | +| **Wrong dependencies** | Unexpected behavior | Fix dependency array | **Example fix:** @@ -112,11 +112,11 @@ useEffect(() => { ### 3. Event Handler Issues -| Bug Type | Symptoms | Solution | -| --------------------- | --------------------- | --------------------------- | -| **Handler not called**| Click doesn't work | Check event binding | -| **Multiple calls** | Handler fires twice | Remove duplicate listeners | -| **Wrong event** | Unexpected behavior | Use correct event type | +| Bug Type | Symptoms | Solution | +| ---------------------- | ------------------- | -------------------------- | +| **Handler not called** | Click doesn't work | Check event binding | +| **Multiple calls** | Handler fires twice | Remove duplicate listeners | +| **Wrong event** | Unexpected behavior | Use correct event type | **Example fix:** @@ -131,11 +131,11 @@ useEffect(() => { ### 4. Rendering Issues -| Bug Type | Symptoms | Solution | -| --------------------- | --------------------- | --------------------------- | -| **Conditional render**| Component disappears | Fix condition logic | -| **Key prop** | Wrong items update | Use stable unique keys | -| **Forced re-render** | Performance issues | Memoize expensive calcs | +| Bug Type | Symptoms | Solution | +| ---------------------- | -------------------- | ----------------------- | +| **Conditional render** | Component disappears | Fix condition logic | +| **Key prop** | Wrong items update | Use stable unique keys | +| **Forced re-render** | Performance issues | Memoize expensive calcs | **Example fix:** @@ -153,11 +153,11 @@ useEffect(() => { ### 5. Accessibility Bugs -| Bug Type | Symptoms | Solution | -| --------------------- | --------------------- | --------------------------- | -| **Missing ARIA** | Screen reader issues | Add ARIA attributes | -| **No keyboard nav** | Can't use keyboard | Add keyboard handlers | -| **Poor contrast** | Hard to read | Fix colors | +| Bug Type | Symptoms | Solution | +| ------------------- | -------------------- | --------------------- | +| **Missing ARIA** | Screen reader issues | Add ARIA attributes | +| **No keyboard nav** | Can't use keyboard | Add keyboard handlers | +| **Poor contrast** | Hard to read | Fix colors | **Example fix:** @@ -182,9 +182,9 @@ useEffect(() => { ```typescript it('should fix the bug', async () => { render(); - + await userEvent.click(screen.getByRole('button')); - + expect(screen.getByText(/expected/i)).toBeInTheDocument(); }); ``` @@ -217,10 +217,10 @@ npm run dev ```typescript /** * Component that was buggy - * + * * @fixed v1.2.3 - Fixed click handler issue */ -export function Component(props: Props): JSX.Element +export function Component(props: Props): JSX.Element; ``` --- @@ -241,10 +241,12 @@ const sortedItems = [...props.items].sort(); ```typescript // ❌ Bug - Object comparison -if (user === prevUser) { } // Always false (different references) +if (user === prevUser) { +} // Always false (different references) // ✅ Fix - Compare values -if (user.id === prevUser.id) { } +if (user.id === prevUser.id) { +} ``` ### 3. Missing Null Checks diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md index 21325ba..29797c6 100644 --- a/.github/instructions/copilot-instructions.md +++ b/.github/instructions/copilot-instructions.md @@ -97,25 +97,25 @@ Toast/ ### JSDoc for Hooks: -```typescript +````typescript /** * Hook for managing notification state * @returns Notification methods and state * @example * ```tsx * const { notify, dismiss, notifications } = useNotifications(); - * + * * const showSuccess = () => { * notify({ type: 'success', message: 'Action completed!' }); * }; * ``` */ export function useNotifications(): UseNotificationsReturn; -``` +```` ### Component Documentation: -```typescript +````typescript export interface ToastProps { /** Toast message content */ message: string; @@ -129,7 +129,7 @@ export interface ToastProps { /** * Toast notification component - * + * * @example * ```tsx * { // ✅ Limit queue size const MAX_NOTIFICATIONS = 5; const addNotification = (notif) => { - setNotifications(prev => [...prev.slice(-MAX_NOTIFICATIONS + 1), notif]); + setNotifications((prev) => [...prev.slice(-MAX_NOTIFICATIONS + 1), notif]); }; ``` diff --git a/.github/instructions/features.instructions.md b/.github/instructions/features.instructions.md index d655ea0..5bd84ec 100644 --- a/.github/instructions/features.instructions.md +++ b/.github/instructions/features.instructions.md @@ -18,6 +18,7 @@ ### Questions to Ask 1. **Already exists?** + ```bash grep -r "ComponentName" src/ ``` @@ -104,10 +105,10 @@ export interface BadgeProps { **Step 2: Implement Component** -```typescript +````typescript /** * Badge component for displaying status or counts - * + * * @example * ```tsx * Active @@ -149,7 +150,7 @@ export function Badge({ ); } -``` +```` **Step 3: Write Tests** @@ -161,33 +162,33 @@ import { Badge } from './Badge'; describe('Badge', () => { it('should render with text', () => { render(Active); - + expect(screen.getByRole('status')).toHaveTextContent('Active'); }); it('should apply variant styles', () => { render(Success); - + const badge = screen.getByRole('status'); expect(badge.className).toMatch(/bg-green-100/); }); it('should apply size classes', () => { render(Large); - + const badge = screen.getByRole('status'); expect(badge.className).toMatch(/text-base/); }); it('should accept custom className', () => { render(Test); - + expect(screen.getByRole('status')).toHaveClass('custom-class'); }); it('should have no accessibility violations', async () => { const { container } = render(Test); - + const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -214,26 +215,23 @@ export type { BadgeProps } from './components/Badge'; **Step 1: Implement Hook** -```typescript +````typescript // src/hooks/use-local-storage.ts import { useState, useEffect } from 'react'; /** * Hook for syncing state with localStorage - * + * * @param key - localStorage key * @param initialValue - Default value * @returns Tuple of [value, setValue] - * + * * @example * ```tsx * const [name, setName] = useLocalStorage('name', 'Guest'); * ``` */ -export function useLocalStorage( - key: string, - initialValue: T -): [T, (value: T) => void] { +export function useLocalStorage(key: string, initialValue: T): [T, (value: T) => void] { const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); @@ -255,7 +253,7 @@ export function useLocalStorage( return [storedValue, setValue]; } -``` +```` **Step 2: Write Tests** @@ -270,33 +268,27 @@ describe('useLocalStorage', () => { }); it('should initialize with default value', () => { - const { result } = renderHook(() => - useLocalStorage('test', 'default') - ); - + const { result } = renderHook(() => useLocalStorage('test', 'default')); + expect(result.current[0]).toBe('default'); }); it('should update localStorage when value changes', () => { - const { result } = renderHook(() => - useLocalStorage('test', 'initial') - ); - + const { result } = renderHook(() => useLocalStorage('test', 'initial')); + act(() => { result.current[1]('updated'); }); - + expect(result.current[0]).toBe('updated'); expect(localStorage.getItem('test')).toBe('"updated"'); }); it('should read existing value from localStorage', () => { localStorage.setItem('test', '"existing"'); - - const { result } = renderHook(() => - useLocalStorage('test', 'default') - ); - + + const { result } = renderHook(() => useLocalStorage('test', 'default')); + expect(result.current[0]).toBe('existing'); }); }); @@ -372,22 +364,26 @@ export type { BadgeProps } from './components/Badge'; ### Migration Guide Example -```markdown +````markdown ## Breaking Changes in v2.0.0 ### Button Component **Before (v1.x):** + ```tsx ``` +```` **After (v2.0):** + ```tsx ``` Rename `type` prop to `variant` for consistency. + ``` --- @@ -405,3 +401,4 @@ Rename `type` prop to `variant` for consistency. - [ ] Breaking changes documented - [ ] Build succeeds - [ ] PR created +``` diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md index a0f8c8c..162aaa3 100644 --- a/.github/instructions/general.instructions.md +++ b/.github/instructions/general.instructions.md @@ -18,13 +18,13 @@ This is a production-ready React component library providing reusable UI compone ### Key Characteristics -| Characteristic | Description | -| ------------------ | ---------------------------------------------------------------- | -| **Architecture** | Component-based, hooks-first, composable | -| **Styling** | Headless/unstyled by default, customizable | -| **TypeScript** | Fully typed, strict mode enabled | -| **Accessibility** | WCAG 2.1 AA compliant | -| **Testing** | Target: 80%+ coverage | +| Characteristic | Description | +| ----------------- | ------------------------------------------ | +| **Architecture** | Component-based, hooks-first, composable | +| **Styling** | Headless/unstyled by default, customizable | +| **TypeScript** | Fully typed, strict mode enabled | +| **Accessibility** | WCAG 2.1 AA compliant | +| **Testing** | Target: 80%+ coverage | --- @@ -211,10 +211,10 @@ export function Component({ className }: ComponentProps) { ### Component JSDoc -```typescript +````typescript /** * Button component with multiple variants - * + * * @example * ```tsx * * ``` */ -export function Button(props: ButtonProps): JSX.Element -``` +export function Button(props: ButtonProps): JSX.Element; +```` ### Props Interface Documentation diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index aa982e9..237410e 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -15,10 +15,10 @@ ```typescript it('should show error message when form is invalid', async () => { render(); - + const submitButton = screen.getByRole('button', { name: /submit/i }); await userEvent.click(submitButton); - + expect(screen.getByText(/email is required/i)).toBeInTheDocument(); }); ``` @@ -37,12 +37,12 @@ it('should update state when input changes', () => { ## 📊 Coverage Targets -| Layer | Minimum Coverage | Priority | -| --------------- | ---------------- | ----------- | -| **Hooks** | 90%+ | 🔴 Critical | -| **Components** | 80%+ | 🟡 High | -| **Utils** | 85%+ | 🟡 High | -| **Context** | 90%+ | 🔴 Critical | +| Layer | Minimum Coverage | Priority | +| -------------- | ---------------- | ----------- | +| **Hooks** | 90%+ | 🔴 Critical | +| **Components** | 80%+ | 🟡 High | +| **Utils** | 85%+ | 🟡 High | +| **Context** | 90%+ | 🔴 Critical | **Overall Target**: 80%+ @@ -62,10 +62,10 @@ src/components/Button/ ### Naming Convention -| Code File | Test File | -| -------------- | --------------- | -| `Button.tsx` | `Button.test.tsx` | -| `use-auth.ts` | `use-auth.test.ts` | +| Code File | Test File | +| ------------- | ------------------ | +| `Button.tsx` | `Button.test.tsx` | +| `use-auth.ts` | `use-auth.test.ts` | --- @@ -82,7 +82,7 @@ import { Button } from './Button'; describe('Button', () => { it('should render with text', () => { render(); - + expect(screen.getByRole('button', { name: /click me/i })) .toBeInTheDocument(); }); @@ -90,15 +90,15 @@ describe('Button', () => { it('should call onClick when clicked', async () => { const handleClick = vi.fn(); render(); - + await userEvent.click(screen.getByRole('button')); - + expect(handleClick).toHaveBeenCalledTimes(1); }); it('should be disabled when disabled prop is true', () => { render(); - + expect(screen.getByRole('button')).toBeDisabled(); }); }); @@ -113,27 +113,27 @@ import { useCounter } from './use-counter'; describe('useCounter', () => { it('should initialize with default value', () => { const { result } = renderHook(() => useCounter()); - + expect(result.current.count).toBe(0); }); it('should increment count', () => { const { result } = renderHook(() => useCounter()); - + act(() => { result.current.increment(); }); - + expect(result.current.count).toBe(1); }); it('should decrement count', () => { const { result } = renderHook(() => useCounter(5)); - + act(() => { result.current.decrement(); }); - + expect(result.current.count).toBe(4); }); }); @@ -149,17 +149,17 @@ describe('useCounter', () => { ```typescript // ✅ BEST - By role (accessible) -screen.getByRole('button', { name: /submit/i }) -screen.getByRole('textbox', { name: /email/i }) +screen.getByRole('button', { name: /submit/i }); +screen.getByRole('textbox', { name: /email/i }); // ✅ GOOD - By label text -screen.getByLabelText(/email/i) +screen.getByLabelText(/email/i); // ⚠️ OK - By test ID (last resort) -screen.getByTestId('submit-button') +screen.getByTestId('submit-button'); // ❌ BAD - By class or internal details -container.querySelector('.button-class') +container.querySelector('.button-class'); ``` ### User Interactions @@ -212,17 +212,17 @@ await waitFor(() => { describe('LoginForm', () => { it('should display error for empty email', async () => { render(); - + const submitBtn = screen.getByRole('button', { name: /login/i }); await userEvent.click(submitBtn); - + expect(screen.getByText(/email is required/i)).toBeInTheDocument(); }); it('should call onSuccess when login succeeds', async () => { const onSuccess = vi.fn(); render(); - + await userEvent.type( screen.getByLabelText(/email/i), 'test@example.com' @@ -232,7 +232,7 @@ describe('LoginForm', () => { 'password123' ); await userEvent.click(screen.getByRole('button', { name: /login/i })); - + await waitFor(() => { expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ email: 'test@example.com' @@ -257,13 +257,13 @@ describe('LoginForm', () => { describe('useAuth', () => { it('should login user', async () => { const { result } = renderHook(() => useAuth()); - + await act(async () => { await result.current.login('test@example.com', 'password'); }); - + expect(result.current.user).toEqual({ - email: 'test@example.com' + email: 'test@example.com', }); expect(result.current.isAuthenticated).toBe(true); }); @@ -271,10 +271,10 @@ describe('useAuth', () => { it('should cleanup on unmount', () => { const cleanup = vi.fn(); vi.spyOn(global, 'removeEventListener').mockImplementation(cleanup); - + const { unmount } = renderHook(() => useAuth()); unmount(); - + expect(cleanup).toHaveBeenCalled(); }); }); @@ -291,7 +291,7 @@ expect.extend(toHaveNoViolations); it('should have no accessibility violations', async () => { const { container } = render(); - + const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -329,7 +329,7 @@ global.fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ data: 'mocked' }), - }) + }), ); ``` diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 378276e..72e7140 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -26,7 +26,6 @@ jobs: permissions: contents: read - # Config stays in the workflow file (token stays in repo secrets) env: SONAR_HOST_URL: 'https://sonarcloud.io' diff --git a/README.md b/README.md index 539fe85..b79104e 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,180 @@ -# React TypeScript DeveloperKit (Template) +# NotificationKit-UI -Template repository for building reusable React TypeScript **npm libraries** -(components + hooks + utilities). +NotificationKit-UI is a React + TypeScript notification library with multiple variants, configurable behavior, accessibility support, and route-aware clearing. -## What you get +## Features -- ESM + CJS + Types build (tsup) -- Vitest testing -- ESLint + Prettier (flat config) -- Changesets (manual release flow, no automation PR) -- Husky (pre-commit + pre-push) -- Enforced public API via `src/index.ts` -- Dependency-free styling (Tailwind-compatible by convention only) -- `react` and `react-dom` as peerDependencies +- Notification types: `success`, `error`, `warning`, `info`, `loading`, `default` +- Positions: `top-left`, `top-center`, `top-right`, `center`, `bottom-left`, `bottom-center`, `bottom-right` +- Configurable animation: `slide`, `fade`, `scale` +- Auto-dismiss with per-notification override +- Optional action buttons +- Optional close button +- Pause on hover and pause on focus +- Queue limit (FIFO) with history support +- Route-aware clearing with `clearOnNavigate` +- Dark mode compatible styles +- RTL-ready Tailwind setup +- Accessibility: ARIA roles, live-region announcements, keyboard escape-to-dismiss -## Package structure +## Installation -- `src/components` – reusable UI components -- `src/hooks` – reusable React hooks -- `src/utils` – framework-agnostic utilities -- `src/index.ts` – **only public API** (no deep imports allowed) +```bash +npm install @ciscode/ui-notification-kit +``` -Anything not exported from `src/index.ts` is considered private. +Also ensure host app peer dependencies are installed: -## Scripts +- `react` +- `react-dom` -- `npm run build` – build to `dist/` (tsup) -- `npm test` – run tests (vitest) -- `npm run typecheck` – TypeScript typecheck -- `npm run lint` – ESLint -- `npm run format` / `npm run format:write` – Prettier -- `npx changeset` – create a changeset +## Styling -## Release flow (summary) +Import package styles once in your app entry file: -- Work on a `feature` branch from `develop` -- Merge to `develop` -- Add a changeset for user-facing changes: `npx changeset` -- Promote `develop` → `master` -- Tag `vX.Y.Z` to publish (npm OIDC) +```ts +import '@ciscode/ui-notification-kit/style.css'; +``` -This repository is a **template**. Teams should clone it and focus only on -library logic, not tooling or release mechanics. +## Quick Start + +```tsx +import React from 'react'; +import { NotificationProvider, useNotification } from '@ciscode/ui-notification-kit'; +import '@ciscode/ui-notification-kit/style.css'; + +function Demo() { + const { success, error, loading, update } = useNotification(); + + const runTask = async () => { + const pending = loading({ + title: 'Please wait', + message: 'Processing request...', + autoDismiss: false, + }); + + try { + await new Promise((resolve) => setTimeout(resolve, 1200)); + update({ + id: pending.id, + type: 'success', + title: 'Done', + message: 'Operation completed', + autoDismiss: true, + }); + } catch { + error({ title: 'Failed', message: 'Something went wrong' }); + } + }; + + return ; +} + +export default function App() { + return ( + + + + ); +} +``` + +## Provider API + +`NotificationProvider` props: + +- `config?: NotificationProviderConfig` +- `navigationKey?: string | number` + +`navigationKey` can be tied to router location changes. When it changes, notifications with `clearOnNavigate: true` are removed while others remain visible. + +### Example with React Router + +```tsx +import { useLocation } from 'react-router-dom'; +import { NotificationProvider } from '@ciscode/ui-notification-kit'; + +function RootLayout({ children }: { children: React.ReactNode }) { + const location = useLocation(); + + return {children}; +} +``` + +## Hook API + +`useNotification()` returns: + +- `state` +- `config` +- `notify(config)` +- `success(config)` +- `error(config)` +- `warning(config)` +- `info(config)` +- `loading(config)` +- `defaultNotification(config)` +- `update({ id, ...patch })` +- `dismiss(id)` +- `clearAll()` +- `restore(id)` + +## Notification Config + +Main fields you can pass to `notify` and typed helper methods: + +- `title?: string` +- `message?: string` +- `body?: ReactNode` +- `type?: NotificationType` +- `position?: NotificationPosition` +- `animation?: { type: 'slide' | 'fade' | 'scale'; durationMs: number }` +- `autoDismiss?: boolean` +- `durationMs?: number` +- `pauseOnHover?: boolean` +- `pauseOnFocus?: boolean` +- `closeButton?: boolean` +- `clearOnNavigate?: boolean` +- `actions?: { label: string; onClick: () => void }[]` +- `icon?: ReactNode | null` +- `onClick?: () => void` +- `ariaRole?: 'status' | 'alert'` + +## Provider Defaults + +`NotificationProviderConfig` supports: + +- `maxVisible` (default: `5`) +- `defaultType` (default: `default`) +- `defaultPosition` (default: `top-right`) +- `defaultAnimation` (default: `{ type: 'slide', durationMs: 300 }`) +- `defaultAutoDismiss` (default: `true`) +- `defaultDurationMs` (default: `4000`) +- `defaultPauseOnHover` (default: `true`) +- `defaultPauseOnFocus` (default: `true`) +- `defaultCloseButton` (default: `true`) +- `defaultClearOnNavigate` (default: `false`) +- `defaultAriaRole` (default: `status`) +- `defaultIcon` (default: `null`) +- `historyLimit` (default: `20`) + +## Accessibility Notes + +- Uses ARIA role per notification (`status` or `alert`) +- Adds live-region announcements for screen readers +- Supports keyboard escape to dismiss +- Supports pause-on-focus for better readability + +## Compatibility Notes + +- Built with React and TypeScript for host apps using modern React (including environments like WidgetKit-UI/comptaleyes frontend) +- Tailwind-compatible output CSS is published as `@ciscode/ui-notification-kit/style.css` +- Distributed in ESM + CJS with type declarations + +## Development Scripts + +- `npm run build` +- `npm run typecheck` +- `npm test` +- `npm run lint` +- `npm run format` diff --git a/package-lock.json b/package-lock.json index c4a7fb9..1eb0e91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@eslint/js": "^9.39.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "autoprefixer": "^10.4.24", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", @@ -24,8 +25,11 @@ "husky": "^9.1.7", "jsdom": "^28.1.0", "lint-staged": "^15.2.10", + "postcss": "^8.5.6", "prettier": "^3.4.2", "rimraf": "^6.0.1", + "tailwindcss": "^3.4.19", + "tailwindcss-rtl": "^0.9.0", "tsup": "^8.3.5", "typescript": "^5.7.2", "typescript-eslint": "^8.50.1", @@ -52,6 +56,19 @@ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "license": "MIT" }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", @@ -2614,6 +2631,27 @@ "dev": true, "license": "MIT" }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2798,6 +2836,43 @@ "node": ">= 0.4" } }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2854,6 +2929,19 @@ "require-from-string": "^2.0.2" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2998,10 +3086,20 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { @@ -3234,6 +3332,19 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "license": "MIT" }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cssstyle": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", @@ -3423,6 +3534,13 @@ "node": ">=8" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3436,6 +3554,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4352,6 +4477,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -4958,6 +5097,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -5384,6 +5536,16 @@ "node": ">= 0.4" } }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -5970,6 +6132,16 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -6009,6 +6181,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6445,6 +6627,71 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -6488,6 +6735,53 @@ } } }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6639,6 +6933,26 @@ "dev": true, "license": "MIT" }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -7563,6 +7877,136 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-rtl": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz", + "integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -8058,6 +8502,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index c43fa50..dbe4fda 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,13 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", + "import": "./dist/index.js", "require": "./dist/index.cjs" - } + }, + "./style.css": "./dist/style.css" }, "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "publishConfig": { "access": "public" @@ -36,6 +37,7 @@ "@eslint/js": "^9.39.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "autoprefixer": "^10.4.24", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", @@ -43,16 +45,20 @@ "husky": "^9.1.7", "jsdom": "^28.1.0", "lint-staged": "^15.2.10", + "postcss": "^8.5.6", "prettier": "^3.4.2", "rimraf": "^6.0.1", + "tailwindcss": "^3.4.19", + "tailwindcss-rtl": "^0.9.0", "tsup": "^8.3.5", "typescript": "^5.7.2", "typescript-eslint": "^8.50.1", "vitest": "^2.1.8" }, "scripts": { - "clean": "rimraf dist *.tsbuildinfo && rm -rf coverage", - "build": "tsup", + "clean": "rimraf dist *.tsbuildinfo", + "build": "tsup && npm run build:css", + "build:css": "tailwindcss -i ./src/assets/styles/style.css -o ./dist/style.css --minify", "dev": "tsup --watch", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/__tests__/NotificationItem.a11y.test.tsx b/src/__tests__/NotificationItem.a11y.test.tsx new file mode 100644 index 0000000..9610f65 --- /dev/null +++ b/src/__tests__/NotificationItem.a11y.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { NotificationItem } from '../components/NotificationItem.js'; +import React from 'react'; + +describe('NotificationItem accessibility', () => { + it('has proper ARIA attributes', () => { + render( + {}} + />, + ); + const notification = screen.getByRole('status'); + expect(notification).toHaveAttribute('aria-describedby'); + expect(screen.getByText('Accessible')).toBeInTheDocument(); + expect(screen.getByText('Notification')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/NotificationProvider.test.tsx b/src/__tests__/NotificationProvider.test.tsx new file mode 100644 index 0000000..ae72d04 --- /dev/null +++ b/src/__tests__/NotificationProvider.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { NotificationProvider } from '../components/NotificationProvider.js'; +import { useNotification } from '../hooks/useNotification.js'; +import React from 'react'; + +describe('NotificationProvider', () => { + function TestComponent() { + const { notify, dismiss, state } = useNotification(); + return ( + <> + + + + ); + } + + it('renders notifications and allows dismiss', () => { + render( + + + , + ); + fireEvent.click(screen.getByText('Show')); + expect(screen.getByText('Test')).toBeInTheDocument(); + expect(screen.getByText('Hello')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Dismiss')); + expect(screen.queryByText('Test')).not.toBeInTheDocument(); + }); + + it('clears only clearOnNavigate notifications when navigationKey changes', () => { + function Wrapper() { + const [route, setRoute] = React.useState(1); + + return ( + + setRoute((prev) => prev + 1)} /> + + ); + } + + function RouteAwareTest({ onNavigate }: { onNavigate: () => void }) { + const { notify } = useNotification(); + + return ( + <> + + + + + ); + } + + render(); + + fireEvent.click(screen.getByText('Add transient')); + fireEvent.click(screen.getByText('Add persistent')); + + expect(screen.getByText('Transient')).toBeInTheDocument(); + expect(screen.getByText('Persistent')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Navigate')); + + expect(screen.queryByText('Transient')).not.toBeInTheDocument(); + expect(screen.getByText('Persistent')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/notificationStore.test.ts b/src/__tests__/notificationStore.test.ts new file mode 100644 index 0000000..4409709 --- /dev/null +++ b/src/__tests__/notificationStore.test.ts @@ -0,0 +1,65 @@ +import { NotificationStore } from '../store/notificationStore.js'; +import { describe, expect, it } from 'vitest'; + +describe('NotificationStore', () => { + it('adds notifications and enforces maxVisible', () => { + const store = new NotificationStore({ maxVisible: 2 }); + store.add({ title: 'First' }); + store.add({ title: 'Second' }); + store.add({ title: 'Third' }); + const state = store.getState(); + expect(state.notifications.length).toBe(2); + expect(state.notifications[0].title).toBe('Second'); + expect(state.notifications[1].title).toBe('Third'); + }); + + it('moves overflowed notifications to history', () => { + const store = new NotificationStore({ maxVisible: 2 }); + store.add({ title: 'First' }); + store.add({ title: 'Second' }); + store.add({ title: 'Third' }); + + const state = store.getState(); + expect(state.history.length).toBe(1); + expect(state.history[0].title).toBe('First'); + }); + + it('dismisses notifications and adds to history', () => { + const store = new NotificationStore({ maxVisible: 2 }); + const n1 = store.add({ title: 'A' }); + store.dismiss(n1.id); + const state = store.getState(); + expect(state.notifications.length).toBe(0); + expect(state.history.length).toBe(1); + expect(state.history[0].title).toBe('A'); + }); + + it('clears all notifications and moves them to history', () => { + const store = new NotificationStore({ maxVisible: 2 }); + store.add({ title: 'A' }); + store.add({ title: 'B' }); + store.clearAll(); + const state = store.getState(); + expect(state.notifications.length).toBe(0); + expect(state.history.length).toBe(2); + }); + + it('restores notifications from history', () => { + const store = new NotificationStore({ maxVisible: 2 }); + const n1 = store.add({ title: 'A' }); + store.dismiss(n1.id); + store.restore(n1.id); + const state = store.getState(); + expect(state.notifications.length).toBe(1); + expect(state.notifications[0].title).toBe('A'); + expect(state.history.length).toBe(0); + }); + + it('updates notifications by id', () => { + const store = new NotificationStore(); + const n1 = store.add({ title: 'A', message: 'Old' }); + store.update({ id: n1.id, message: 'New' }); + const state = store.getState(); + expect(state.notifications[0].message).toBe('New'); + }); +}); diff --git a/src/assets/styles/style.css b/src/assets/styles/style.css new file mode 100644 index 0000000..7467a29 --- /dev/null +++ b/src/assets/styles/style.css @@ -0,0 +1,18 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Accessibility: Screen reader only class */ +@layer utilities { + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } +} diff --git a/src/components/NoopButton.tsx b/src/components/NoopButton.tsx deleted file mode 100644 index 4bee846..0000000 --- a/src/components/NoopButton.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ButtonHTMLAttributes } from 'react'; -import { useNoop } from '../hooks'; - -export type NoopButtonProps = ButtonHTMLAttributes; - -export function NoopButton(props: NoopButtonProps) { - const onClick = useNoop(); - return + ))} + + ); +} diff --git a/src/components/NotificationContainer.tsx b/src/components/NotificationContainer.tsx new file mode 100644 index 0000000..8f8e22d --- /dev/null +++ b/src/components/NotificationContainer.tsx @@ -0,0 +1,78 @@ +import type { NotificationPosition, NotificationRecord } from '../models/index.js'; +import { NotificationItem } from './NotificationItem.js'; + +export type NotificationContainerProps = { + position: NotificationPosition; + items: NotificationRecord[]; + onDismiss: (id: string) => void; +}; + +const positionClassMap: Record = { + 'top-left': 'top-4 left-4 items-start', + 'top-center': 'top-4 left-1/2 -translate-x-1/2 items-center', + 'top-right': 'top-4 right-4 items-end', + center: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 items-center', + 'bottom-left': 'bottom-4 left-4 items-start', + 'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2 items-center', + 'bottom-right': 'bottom-4 right-4 items-end', +}; + +export function NotificationContainer({ position, items, onDismiss }: NotificationContainerProps) { + if (items.length === 0) { + return null; + } + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +export function NotificationContainerGroup({ + items, + onDismiss, +}: { + items: NotificationRecord[]; + onDismiss: (id: string) => void; +}) { + const groups = items.reduce>( + (acc, item) => { + const list = acc[item.position] ?? []; + list.push(item); + acc[item.position] = list; + return acc; + }, + { + 'top-left': [], + 'top-center': [], + 'top-right': [], + center: [], + 'bottom-left': [], + 'bottom-center': [], + 'bottom-right': [], + }, + ); + + return ( + <> + {Object.entries(groups).map(([position, group]) => ( + + ))} + + ); +} diff --git a/src/components/NotificationIcon.tsx b/src/components/NotificationIcon.tsx new file mode 100644 index 0000000..7902eca --- /dev/null +++ b/src/components/NotificationIcon.tsx @@ -0,0 +1,63 @@ +import type { NotificationRecord } from '../models/index.js'; + +const typeIconMap: Record = { + success: ( + + ), + error: ( + + ), + warning: ( + + ), + info: ( + + ), + loading: ( + + + + + ), + default: ( + + ), +}; + +export function NotificationIcon({ item }: { item: NotificationRecord }) { + if (item.icon === null) { + return null; + } + + if (item.icon) { + return
{item.icon}
; + } + + return
{typeIconMap[item.type]}
; +} diff --git a/src/components/NotificationIcons.css b/src/components/NotificationIcons.css new file mode 100644 index 0000000..8cd3418 --- /dev/null +++ b/src/components/NotificationIcons.css @@ -0,0 +1,44 @@ +@layer components { + .animate-notify-slide { + animation: notify-slide 0.3s ease-out; + } + + .animate-notify-fade { + animation: notify-fade 0.2s ease-out; + } + + .animate-notify-scale { + animation: notify-scale 0.2s ease-out; + } + + @keyframes notify-slide { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes notify-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes notify-scale { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } + } +} diff --git a/src/components/NotificationItem.tsx b/src/components/NotificationItem.tsx new file mode 100644 index 0000000..acc0c42 --- /dev/null +++ b/src/components/NotificationItem.tsx @@ -0,0 +1,151 @@ +import type { MouseEvent } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { NotificationRecord } from '../models/index.js'; +import { useLiveRegion } from '../hooks/useAccessibility.js'; +import { NotificationActionList } from './NotificationActionList.js'; +import { NotificationIcon } from './NotificationIcon.js'; +import { NotificationProgress } from './NotificationProgress.js'; + +export type NotificationItemProps = { + item: NotificationRecord; + onDismiss: (id: string) => void; +}; + +const typeStyles: Record = { + success: + 'border-emerald-500/40 bg-emerald-50 text-emerald-950 dark:bg-emerald-900/40 dark:text-emerald-50', + error: 'border-rose-500/40 bg-rose-50 text-rose-950 dark:bg-rose-900/40 dark:text-rose-50', + warning: 'border-amber-500/40 bg-amber-50 text-amber-950 dark:bg-amber-900/40 dark:text-amber-50', + info: 'border-sky-500/40 bg-sky-50 text-sky-950 dark:bg-sky-900/40 dark:text-sky-50', + loading: 'border-slate-500/40 bg-slate-50 text-slate-950 dark:bg-slate-900/40 dark:text-slate-50', + default: + 'border-slate-200 bg-white text-slate-950 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-50', +}; + +export function NotificationItem({ item, onDismiss }: NotificationItemProps) { + const [isPaused, setIsPaused] = useState(false); + const [remaining, setRemaining] = useState(item.durationMs); + const timerRef = useRef(null); + const startRef = useRef(null); + const itemRef = useRef(null); + const descriptionId = useId(); + + const canDismiss = item.autoDismiss && item.durationMs > 0; + + // Announce notification to screen readers + const announcementText = [item.title, item.message].filter(Boolean).join(': ') || 'Notification'; + useLiveRegion(announcementText, item.ariaRole === 'alert' ? 'assertive' : 'polite'); + + useEffect(() => { + if (!canDismiss || isPaused) { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + return; + } + + startRef.current = Date.now(); + timerRef.current = window.setTimeout(() => { + onDismiss(item.id); + }, remaining); + + return () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + }; + }, [canDismiss, isPaused, item.id, onDismiss, remaining]); + + const handlePause = () => { + if (!canDismiss || !startRef.current) { + return; + } + const elapsed = Date.now() - startRef.current; + setRemaining((prev) => Math.max(prev - elapsed, 0)); + startRef.current = null; + setIsPaused(true); + }; + + const handleResume = () => { + if (!canDismiss) { + return; + } + setIsPaused(false); + }; + + const handleClose = (event: MouseEvent) => { + event.stopPropagation(); + onDismiss(item.id); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Escape key to dismiss + if (event.key === 'Escape') { + event.preventDefault(); + onDismiss(item.id); + } + }; + + const handleClick = () => { + item.onClick?.(); + }; + + const shouldPauseOnHover = item.pauseOnHover && canDismiss; + const shouldPauseOnFocus = item.pauseOnFocus && canDismiss; + + const animationClass = useMemo(() => { + switch (item.animation.type) { + case 'fade': + return 'animate-notify-fade'; + case 'scale': + return 'animate-notify-scale'; + default: + return 'animate-notify-slide'; + } + }, [item.animation.type]); + + return ( +
+
+ +
+ {item.title ?

{item.title}

: null} + {item.message ? ( +

{item.message}

+ ) : null} + {item.body ? ( +
{item.body}
+ ) : null} +
+ {item.closeButton ? ( + + ) : null} +
+ {item.actions && item.actions.length > 0 ? ( + + ) : null} + {canDismiss ? ( + + ) : null} +
+ ); +} diff --git a/src/components/NotificationProgress.tsx b/src/components/NotificationProgress.tsx new file mode 100644 index 0000000..e774786 --- /dev/null +++ b/src/components/NotificationProgress.tsx @@ -0,0 +1,18 @@ +export function NotificationProgress({ + remaining, + duration, +}: { + remaining: number; + duration: number; +}) { + const percentage = Math.max(0, Math.min(100, (remaining / duration) * 100)); + + return ( +
+
+
+ ); +} diff --git a/src/components/NotificationProvider.tsx b/src/components/NotificationProvider.tsx new file mode 100644 index 0000000..e690758 --- /dev/null +++ b/src/components/NotificationProvider.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + NotificationConfig, + NotificationProviderConfig, + NotificationRecord, + NotificationStoreState, + NotificationUpdate, +} from '../models/index.js'; +import { NotificationContext } from '../context/NotificationContext.js'; +import { NotificationStore } from '../store/index.js'; +import { NotificationViewport } from './NotificationViewport.js'; + +export type NotificationProviderProps = { + children: ReactNode; + config?: NotificationProviderConfig; + navigationKey?: string | number; +}; + +export function NotificationProvider({ + children, + config, + navigationKey, +}: NotificationProviderProps) { + const [store] = useState(() => new NotificationStore(config)); + const [state, setState] = useState(() => store.getState()); + const navigationKeyRef = useRef(navigationKey); + + // Subscribe to store mutation events — this is the only correct way to react to external store changes + useEffect(() => { + return store.subscribe(() => { + setState(store.getState()); + }); + }, [store]); + + // Propagate config changes to the store + useEffect(() => { + if (config) { + store.setProviderConfig(config); + } + }, [config, store]); + + // Clear route-scoped notifications when navigation key changes + useEffect(() => { + if (navigationKey === undefined || navigationKey === navigationKeyRef.current) { + navigationKeyRef.current = navigationKey; + return; + } + navigationKeyRef.current = navigationKey; + store.clearOnNavigate(); + }, [navigationKey, store]); + + const notify = useCallback( + (input: NotificationConfig): NotificationRecord => { + return store.add(input); + }, + [store], + ); + + const withType = useCallback( + (type: NotificationConfig['type']) => + (input: NotificationConfig): NotificationRecord => + notify({ ...input, type }), + [notify], + ); + + const update = useCallback( + (next: NotificationUpdate) => { + store.update(next); + }, + [store], + ); + + const dismiss = useCallback( + (id: string) => { + store.dismiss(id); + }, + [store], + ); + + const clearAll = useCallback(() => { + store.clearAll(); + }, [store]); + + const restore = useCallback( + (id: string) => { + store.restore(id); + }, + [store], + ); + + const value = useMemo( + () => ({ + state, + config: store.getProviderConfig(), + notify, + success: withType('success'), + error: withType('error'), + warning: withType('warning'), + info: withType('info'), + loading: withType('loading'), + defaultNotification: withType('default'), + update, + dismiss, + clearAll, + restore, + }), + [state, notify, update, dismiss, clearAll, restore, store, withType], + ); + + return ( + + {children} + + + ); +} diff --git a/src/components/NotificationViewport.tsx b/src/components/NotificationViewport.tsx new file mode 100644 index 0000000..9e2e467 --- /dev/null +++ b/src/components/NotificationViewport.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import type { NotificationRecord } from '../models/index.js'; +import { NotificationContainerGroup } from './NotificationContainer.js'; + +export function NotificationViewport({ + items, + onDismiss, +}: { + items: NotificationRecord[]; + onDismiss: (id: string) => void; +}) { + const ordered = useMemo(() => [...items].sort((a, b) => a.createdAt - b.createdAt), [items]); + + if (typeof document === 'undefined') { + return null; + } + + return createPortal( + , + document.body, + ); +} diff --git a/src/components/index.css b/src/components/index.css new file mode 100644 index 0000000..e657961 --- /dev/null +++ b/src/components/index.css @@ -0,0 +1,2 @@ +@import '../assets/styles/style.css'; +@import './NotificationIcons.css'; diff --git a/src/components/index.ts b/src/components/index.ts index 52f8fa8..ade7987 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,7 @@ -// Example placeholder export — replace with real components later. -export const __components_placeholder = true; - -export * from './NoopButton'; +export * from './NotificationProvider.js'; +export * from './NotificationContainer.js'; +export * from './NotificationItem.js'; +export * from './NotificationActionList.js'; +export * from './NotificationIcon.js'; +export * from './NotificationProgress.js'; +export * from './NotificationViewport.js'; diff --git a/src/context/NotificationContext.tsx b/src/context/NotificationContext.tsx new file mode 100644 index 0000000..39d79ad --- /dev/null +++ b/src/context/NotificationContext.tsx @@ -0,0 +1,26 @@ +import { createContext } from 'react'; +import type { + NotificationConfig, + NotificationProviderConfig, + NotificationRecord, + NotificationStoreState, + NotificationUpdate, +} from '../models/index.js'; + +export type NotificationContextValue = { + state: NotificationStoreState; + config: Required; + notify: (config: NotificationConfig) => NotificationRecord; + success: (config: NotificationConfig) => NotificationRecord; + error: (config: NotificationConfig) => NotificationRecord; + warning: (config: NotificationConfig) => NotificationRecord; + info: (config: NotificationConfig) => NotificationRecord; + loading: (config: NotificationConfig) => NotificationRecord; + defaultNotification: (config: NotificationConfig) => NotificationRecord; + update: (update: NotificationUpdate) => void; + dismiss: (id: string) => void; + clearAll: () => void; + restore: (id: string) => void; +}; + +export const NotificationContext = createContext(null); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 6a94ddd..4c37517 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,3 @@ -// Example placeholder export — replace with real hooks later. -export const __hooks_placeholder = true; - -export * from './useNoop'; +export * from './useNotification.js'; +export * from './useAccessibility.js'; +export * from './useClearOnNavigate.js'; diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts new file mode 100644 index 0000000..647e1aa --- /dev/null +++ b/src/hooks/useAccessibility.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect } from 'react'; + +/** + * Hook for managing focus trap behavior within a container. + * Keeps focus within the container when tabbing. + */ +export function useFocusTrap(containerRef: React.RefObject) { + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'Tab' || !containerRef.current) { + return; + } + + const focusableElements = containerRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + if (focusableElements.length === 0) { + event.preventDefault(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement; + + if (event.shiftKey) { + if (activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + } else { + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + }, + [containerRef], + ); + + useEffect(() => { + const element = containerRef.current; + if (!element) { + return; + } + + element.addEventListener('keydown', handleKeyDown); + return () => { + element.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown, containerRef]); +} + +/** + * Hook for managing live region announcements. + * Announces messages to screen readers. + */ +export function useLiveRegion(message: string, priority: 'polite' | 'assertive' = 'assertive') { + useEffect(() => { + if (!message) { + return; + } + + let liveRegion = document.querySelector( + `[data-notification-live-region="true"][data-priority="${priority}"]`, + ); + + if (!liveRegion) { + liveRegion = document.createElement('div'); + liveRegion.setAttribute('aria-live', priority); + liveRegion.setAttribute('aria-atomic', 'true'); + liveRegion.setAttribute('data-notification-live-region', 'true'); + liveRegion.setAttribute('data-priority', priority); + liveRegion.className = 'sr-only'; + document.body.appendChild(liveRegion); + } + + liveRegion.textContent = message; + + return () => { + if (liveRegion && !liveRegion.textContent) { + liveRegion.remove(); + } + }; + }, [message, priority]); +} diff --git a/src/hooks/useClearOnNavigate.ts b/src/hooks/useClearOnNavigate.ts new file mode 100644 index 0000000..e3fb2fc --- /dev/null +++ b/src/hooks/useClearOnNavigate.ts @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; + +/** + * Hook to clear notifications on route change. + * Requires a clearAll callback and a location key. + */ +export function useClearOnNavigate(clearAll: () => void, locationKey: string) { + useEffect(() => { + clearAll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [locationKey]); +} diff --git a/src/hooks/useNoop.ts b/src/hooks/useNoop.ts deleted file mode 100644 index a0d82a3..0000000 --- a/src/hooks/useNoop.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useCallback } from 'react'; -import { noop } from '../utils'; - -export function useNoop() { - return useCallback(() => noop(), []); -} diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts new file mode 100644 index 0000000..9c9b008 --- /dev/null +++ b/src/hooks/useNotification.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { NotificationContext } from '../context/NotificationContext.js'; + +export function useNotification() { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotification must be used within a NotificationProvider'); + } + + return context; +} diff --git a/src/index.ts b/src/index.ts index c55977d..b01c53c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ -export * from './components'; -export * from './hooks'; -export * from './utils'; +export * from './components/index.js'; +export * from './hooks/index.js'; +export * from './models/index.js'; +export * from './store/index.js'; +export * from './utils/index.js'; diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..c103055 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1 @@ +export * from './notification.js'; diff --git a/src/models/notification.ts b/src/models/notification.ts new file mode 100644 index 0000000..fd6154b --- /dev/null +++ b/src/models/notification.ts @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react'; + +export type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading' | 'default'; + +export type NotificationPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'center' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + +export type NotificationAnimationType = 'slide' | 'fade' | 'scale'; + +export type NotificationAnimation = { + type: NotificationAnimationType; + durationMs: number; +}; + +export type NotificationAction = { + label: string; + onClick: () => void; +}; + +export type NotificationContent = { + title?: string; + message?: string; + body?: ReactNode; +}; + +export type NotificationIconNode = ReactNode; + +export type NotificationOptions = { + id?: string; + type?: NotificationType; + position?: NotificationPosition; + animation?: NotificationAnimation; + autoDismiss?: boolean; + durationMs?: number; + pauseOnHover?: boolean; + pauseOnFocus?: boolean; + closeButton?: boolean; + clearOnNavigate?: boolean; + onClick?: () => void; + actions?: NotificationAction[]; + icon?: NotificationIconNode | null; + ariaRole?: 'status' | 'alert'; +}; + +export type NotificationConfig = NotificationContent & NotificationOptions; + +export type NotificationRecord = NotificationConfig & { + id: string; + type: NotificationType; + position: NotificationPosition; + animation: NotificationAnimation; + autoDismiss: boolean; + durationMs: number; + pauseOnHover: boolean; + pauseOnFocus: boolean; + closeButton: boolean; + clearOnNavigate: boolean; + createdAt: number; + state: 'visible' | 'dismissing'; +}; + +export type NotificationUpdate = Partial & { id: string }; + +export type NotificationHistoryItem = NotificationRecord & { + dismissedAt: number; +}; + +export type NotificationProviderConfig = { + maxVisible?: number; + defaultType?: NotificationType; + defaultPosition?: NotificationPosition; + defaultAnimation?: NotificationAnimation; + defaultAutoDismiss?: boolean; + defaultDurationMs?: number; + defaultPauseOnHover?: boolean; + defaultPauseOnFocus?: boolean; + defaultCloseButton?: boolean; + defaultClearOnNavigate?: boolean; + defaultAriaRole?: 'status' | 'alert'; + defaultIcon?: NotificationIconNode | null; + historyLimit?: number; +}; + +export type NotificationStoreState = { + notifications: NotificationRecord[]; + history: NotificationHistoryItem[]; +}; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..538ae61 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from './notificationStore.js'; diff --git a/src/store/notificationStore.ts b/src/store/notificationStore.ts new file mode 100644 index 0000000..4895ff8 --- /dev/null +++ b/src/store/notificationStore.ts @@ -0,0 +1,209 @@ +import type { + NotificationConfig, + NotificationHistoryItem, + NotificationProviderConfig, + NotificationRecord, + NotificationStoreState, + NotificationUpdate, +} from '../models/index.js'; + +const DEFAULT_ANIMATION = { type: 'slide', durationMs: 300 } as const; + +const DEFAULT_PROVIDER_CONFIG: Required = { + maxVisible: 5, + defaultType: 'default', + defaultPosition: 'top-right', + defaultAnimation: DEFAULT_ANIMATION, + defaultAutoDismiss: true, + defaultDurationMs: 4000, + defaultPauseOnHover: true, + defaultPauseOnFocus: true, + defaultCloseButton: true, + defaultClearOnNavigate: false, + defaultAriaRole: 'status', + defaultIcon: null, + historyLimit: 20, +}; + +let idCounter = 0; + +function nextId() { + idCounter += 1; + return `nk_${Date.now()}_${idCounter}`; +} + +function toRecord( + config: NotificationConfig, + provider: Required, +): NotificationRecord { + const createdAt = Date.now(); + const id = config.id ?? nextId(); + const type = config.type ?? provider.defaultType; + const position = config.position ?? provider.defaultPosition; + const animation = config.animation ?? provider.defaultAnimation; + const autoDismiss = config.autoDismiss ?? provider.defaultAutoDismiss; + const durationMs = config.durationMs ?? provider.defaultDurationMs; + const pauseOnHover = config.pauseOnHover ?? provider.defaultPauseOnHover; + const pauseOnFocus = config.pauseOnFocus ?? provider.defaultPauseOnFocus; + const closeButton = config.closeButton ?? provider.defaultCloseButton; + const clearOnNavigate = config.clearOnNavigate ?? provider.defaultClearOnNavigate; + const ariaRole = config.ariaRole ?? provider.defaultAriaRole; + const icon = config.icon ?? provider.defaultIcon; + + return { + ...config, + id, + type, + position, + animation, + autoDismiss, + durationMs, + pauseOnHover, + pauseOnFocus, + closeButton, + clearOnNavigate, + ariaRole, + icon, + createdAt, + state: 'visible', + }; +} + +function pushHistory( + history: NotificationHistoryItem[], + item: NotificationRecord, + limit: number, +): NotificationHistoryItem[] { + const updated = [{ ...item, dismissedAt: Date.now() }, ...history]; + if (updated.length <= limit) { + return updated; + } + return updated.slice(0, limit); +} + +export class NotificationStore { + private provider: Required; + private state: NotificationStoreState; + private listeners: Set<() => void> = new Set(); + + constructor(providerConfig?: NotificationProviderConfig) { + this.provider = { ...DEFAULT_PROVIDER_CONFIG, ...providerConfig }; + this.state = { notifications: [], history: [] }; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify() { + this.listeners.forEach((listener) => listener()); + } + + getProviderConfig() { + return this.provider; + } + + getState() { + return this.state; + } + + setProviderConfig(nextConfig: NotificationProviderConfig) { + this.provider = { ...this.provider, ...nextConfig }; + this.notify(); + } + + add(config: NotificationConfig) { + const record = toRecord(config, this.provider); + const notifications = [...this.state.notifications, record]; + const maxVisible = this.provider.maxVisible; + const overflow = + maxVisible > 0 && notifications.length > maxVisible ? notifications.length - maxVisible : 0; + const dismissedByOverflow = overflow > 0 ? notifications.slice(0, overflow) : []; + const trimmed = overflow > 0 ? notifications.slice(overflow) : notifications; + + const history = dismissedByOverflow.reduce( + (acc, item) => pushHistory(acc, item, this.provider.historyLimit), + this.state.history, + ); + + this.state = { + notifications: trimmed, + history, + }; + + this.notify(); + return record; + } + + update(update: NotificationUpdate) { + const notifications = this.state.notifications.map((item) => + item.id === update.id ? { ...item, ...update } : item, + ); + + this.state = { + ...this.state, + notifications, + }; + this.notify(); + } + + dismiss(id: string) { + const target = this.state.notifications.find((item) => item.id === id); + if (!target) { + return; + } + + this.state = { + notifications: this.state.notifications.filter((item) => item.id !== id), + history: pushHistory(this.state.history, target, this.provider.historyLimit), + }; + this.notify(); + } + + clearAll() { + const history = this.state.notifications.reduce( + (acc, item) => pushHistory(acc, item, this.provider.historyLimit), + this.state.history, + ); + + this.state = { + notifications: [], + history, + }; + this.notify(); + } + + clearOnNavigate() { + const toKeep = this.state.notifications.filter((item) => !item.clearOnNavigate); + const toDismiss = this.state.notifications.filter((item) => item.clearOnNavigate); + + const history = toDismiss.reduce( + (acc, item) => pushHistory(acc, item, this.provider.historyLimit), + this.state.history, + ); + + this.state = { + notifications: toKeep, + history, + }; + this.notify(); + } + + restore(id: string) { + const historyItem = this.state.history.find((item) => item.id === id); + if (!historyItem) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { dismissedAt: _dismissedAt, ...rest } = historyItem; + this.add(rest); + this.state = { + ...this.state, + history: this.state.history.filter((item) => item.id !== id), + }; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 6211c64..cb0ff5c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1 @@ -// Example placeholder export — replace with real utils later. -export const __utils_placeholder = true; - -export * from './noop'; +export {}; diff --git a/src/utils/noop.ts b/src/utils/noop.ts deleted file mode 100644 index c3a1aab..0000000 --- a/src/utils/noop.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function noop(): void { - // intentionally empty -} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..c54a8ab --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,16 @@ +import tailwindcssRtl from 'tailwindcss-rtl'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + zIndex: { + 60: '60', + 70: '70', + }, + }, + }, + plugins: [tailwindcssRtl], +}; diff --git a/tailwind.config.mjs b/tailwind.config.mjs new file mode 100644 index 0000000..c54a8ab --- /dev/null +++ b/tailwind.config.mjs @@ -0,0 +1,16 @@ +import tailwindcssRtl from 'tailwindcss-rtl'; + +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{ts,tsx}'], + darkMode: 'class', + theme: { + extend: { + zIndex: { + 60: '60', + 70: '70', + }, + }, + }, + plugins: [tailwindcssRtl], +};