+```
+
+---
+
+## โฟ Accessibility Requirements
+
+### ARIA Attributes
+
+```typescript
+// โ
Include ARIA for screen readers
+
+```
+
+### Keyboard Navigation
+
+```typescript
+// โ
Handle keyboard events
+
e.key === 'Enter' && handleClick()}
+>
+```
+
+---
+
+## ๐งช Testing Philosophy
+
+- **Target**: 80%+ code coverage
+- **Test user interactions**, not implementation
+- **Mock external dependencies**
+- **Test accessibility** with jest-axe
+
+---
+
+## ๐ Documentation Requirements
+
+### Component JSDoc
+
+````typescript
+/**
+ * Button component with multiple variants
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function Button(props: ButtonProps): JSX.Element;
+````
+
+### Props Interface Documentation
+
+```typescript
+export interface ButtonProps {
+ /** Button text content */
+ children: React.ReactNode;
+ /** Click event handler */
+ onClick?: () => void;
+ /** Visual variant */
+ variant?: 'primary' | 'secondary' | 'danger';
+ /** Disable button interaction */
+ disabled?: boolean;
+}
+```
+
+---
+
+## ๐ Development Workflow
+
+1. **Design** - Plan component API and props
+2. **Implement** - Write component following standards
+3. **Test** - Unit tests with React Testing Library
+4. **Document** - JSDoc and examples
+5. **Release** - Semantic versioning
+
+---
+
+## โ ๏ธ Common Gotchas
+
+### 1. Event Handlers
+
+```typescript
+// โ
Use optional callbacks
+onClick?.();
+
+// โ Call without checking
+onClick();
+```
+
+### 2. useEffect Cleanup
+
+```typescript
+// โ
Clean up side effects
+useEffect(() => {
+ const timer = setTimeout(() => {}, 1000);
+ return () => clearTimeout(timer);
+}, []);
+
+// โ Missing cleanup
+useEffect(() => {
+ setTimeout(() => {}, 1000);
+}, []);
+```
+
+### 3. Key Props in Lists
+
+```typescript
+// โ
Unique, stable keys
+{items.map(item =>
{item.name}
)}
+
+// โ Index as key
+{items.map((item, i) =>
{item.name}
)}
+```
+
+---
+
+## ๐ฆ Build Configuration
+
+Ensure proper build setup:
+
+```json
+{
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "files": ["dist"]
+}
+```
+
+---
+
+## ๐ Testing Commands
+
+```bash
+npm test # Run tests
+npm run test:watch # Watch mode
+npm run test:coverage # Coverage report
+```
+
+---
+
+## ๐ Pre-Release Checklist
+
+- [ ] All tests passing
+- [ ] Coverage >= 80%
+- [ ] JSDoc complete
+- [ ] README with examples
+- [ ] CHANGELOG updated
+- [ ] No console.log statements
+- [ ] Accessibility tested
+- [ ] TypeScript strict mode
+- [ ] Build outputs verified
diff --git a/.github/instructions/sonarqube_mcp.instructions.md b/.github/instructions/sonarqube_mcp.instructions.md
new file mode 100644
index 0000000..1e17f37
--- /dev/null
+++ b/.github/instructions/sonarqube_mcp.instructions.md
@@ -0,0 +1,50 @@
+---
+applyTo: '**/*'
+---
+
+These are some guidelines when using the SonarQube MCP server.
+
+# Important Tool Guidelines
+
+## Basic usage
+
+- **IMPORTANT**: After you finish generating or modifying any code files at the very end of the task, you MUST call the `analyze_file_list` tool (if it exists) to analyze the files you created or modified.
+- **IMPORTANT**: When starting a new task, you MUST disable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
+- **IMPORTANT**: When you are done generating code at the very end of the task, you MUST re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
+
+## Project Keys
+
+- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key
+- Don't guess project keys - always look them up
+
+## Code Language Detection
+
+- When analyzing code snippets, try to detect the programming language from the code syntax
+- If unclear, ask the user or make an educated guess based on syntax
+
+## Branch and Pull Request Context
+
+- Many operations support branch-specific analysis
+- If user mentions working on a feature branch, include the branch parameter
+
+## Code Issues and Violations
+
+- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates
+
+# Common Troubleshooting
+
+## Authentication Issues
+
+- SonarQube requires USER tokens (not project tokens)
+- When the error `SonarQube answered with Not authorized` occurs, verify the token type
+
+## Project Not Found
+
+- Use `search_my_sonarqube_projects` to find available projects
+- Verify project key spelling and format
+
+## Code Analysis Issues
+
+- Ensure programming language is correctly specified
+- Remind users that snippet analysis doesn't replace full project scans
+- Provide full file content for better analysis results
diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md
new file mode 100644
index 0000000..237410e
--- /dev/null
+++ b/.github/instructions/testing.instructions.md
@@ -0,0 +1,408 @@
+# Testing Instructions - UI Kit Module
+
+> **Last Updated**: February 2026
+> **Testing Framework**: Vitest + React Testing Library
+> **Coverage Target**: 80%+
+
+---
+
+## ๐ฏ Testing Philosophy
+
+### Test User Behavior, Not Implementation
+
+**โ
Test what users see and do:**
+
+```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();
+});
+```
+
+**โ Don't test implementation details:**
+
+```typescript
+it('should update state when input changes', () => {
+ const { rerender } = render(
);
+ // Testing internal state = implementation detail
+ expect(component.state.value).toBe('test');
+});
+```
+
+---
+
+## ๐ Coverage Targets
+
+| Layer | Minimum Coverage | Priority |
+| -------------- | ---------------- | ----------- |
+| **Hooks** | 90%+ | ๐ด Critical |
+| **Components** | 80%+ | ๐ก High |
+| **Utils** | 85%+ | ๐ก High |
+| **Context** | 90%+ | ๐ด Critical |
+
+**Overall Target**: 80%+
+
+---
+
+## ๐ Test File Organization
+
+### File Placement
+
+Tests live next to components:
+
+```
+src/components/Button/
+ โโโ Button.tsx
+ โโโ Button.test.tsx โ Same directory
+```
+
+### Naming Convention
+
+| Code File | Test File |
+| ------------- | ------------------ |
+| `Button.tsx` | `Button.test.tsx` |
+| `use-auth.ts` | `use-auth.test.ts` |
+
+---
+
+## ๐ญ Test Structure
+
+### Component Test Template
+
+```typescript
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Button } from './Button';
+
+describe('Button', () => {
+ it('should render with text', () => {
+ render(
);
+
+ expect(screen.getByRole('button', { name: /click me/i }))
+ .toBeInTheDocument();
+ });
+
+ 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();
+ });
+});
+```
+
+### Hook Test Template
+
+```typescript
+import { renderHook, act } from '@testing-library/react';
+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);
+ });
+});
+```
+
+---
+
+## ๐ญ Testing Patterns
+
+### Querying Elements
+
+**Prefer accessible queries:**
+
+```typescript
+// โ
BEST - By role (accessible)
+screen.getByRole('button', { name: /submit/i });
+screen.getByRole('textbox', { name: /email/i });
+
+// โ
GOOD - By label text
+screen.getByLabelText(/email/i);
+
+// โ ๏ธ OK - By test ID (last resort)
+screen.getByTestId('submit-button');
+
+// โ BAD - By class or internal details
+container.querySelector('.button-class');
+```
+
+### User Interactions
+
+**Use userEvent over fireEvent:**
+
+```typescript
+import userEvent from '@testing-library/user-event';
+
+// โ
GOOD - userEvent (realistic)
+await userEvent.click(button);
+await userEvent.type(input, 'test@example.com');
+
+// โ BAD - fireEvent (synthetic)
+fireEvent.click(button);
+fireEvent.change(input, { target: { value: 'test' } });
+```
+
+### Async Testing
+
+```typescript
+// โ
Wait for element to appear
+const message = await screen.findByText(/success/i);
+
+// โ
Wait for element to disappear
+await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
+
+// โ
Wait for assertion
+await waitFor(() => {
+ expect(screen.getByText(/loaded/i)).toBeInTheDocument();
+});
+```
+
+---
+
+## ๐งช Test Categories
+
+### 1. Component Tests
+
+**What to test:**
+
+- โ
Rendering with different props
+- โ
User interactions (click, type, etc.)
+- โ
Conditional rendering
+- โ
Error states
+
+**Example:**
+
+```typescript
+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'
+ );
+ await userEvent.type(
+ screen.getByLabelText(/password/i),
+ 'password123'
+ );
+ await userEvent.click(screen.getByRole('button', { name: /login/i }));
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({
+ email: 'test@example.com'
+ }));
+ });
+ });
+});
+```
+
+### 2. Hook Tests
+
+**What to test:**
+
+- โ
Initial state
+- โ
State updates
+- โ
Side effects
+- โ
Cleanup
+
+**Example:**
+
+```typescript
+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',
+ });
+ expect(result.current.isAuthenticated).toBe(true);
+ });
+
+ it('should cleanup on unmount', () => {
+ const cleanup = vi.fn();
+ vi.spyOn(global, 'removeEventListener').mockImplementation(cleanup);
+
+ const { unmount } = renderHook(() => useAuth());
+ unmount();
+
+ expect(cleanup).toHaveBeenCalled();
+ });
+});
+```
+
+### 3. Accessibility Tests
+
+**Use jest-axe:**
+
+```typescript
+import { axe, toHaveNoViolations } from 'jest-axe';
+
+expect.extend(toHaveNoViolations);
+
+it('should have no accessibility violations', async () => {
+ const { container } = render(
);
+
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+});
+```
+
+---
+
+## ๐จ Mocking
+
+### Mocking Context
+
+```typescript
+const mockAuthContext = {
+ user: { id: '1', email: 'test@example.com' },
+ login: vi.fn(),
+ logout: vi.fn(),
+ isAuthenticated: true,
+};
+
+const wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+render(
, { wrapper });
+```
+
+### Mocking API Calls
+
+```typescript
+import { vi } from 'vitest';
+
+global.fetch = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ data: 'mocked' }),
+ }),
+);
+```
+
+---
+
+## ๐งช Test Commands
+
+```bash
+# Run all tests
+npm test
+
+# Watch mode
+npm run test:watch
+
+# Coverage report
+npm run test:coverage
+
+# UI mode (Vitest)
+npm run test:ui
+```
+
+---
+
+## โ ๏ธ Common Mistakes
+
+### 1. Not Waiting for Async Updates
+
+```typescript
+// โ BAD - Missing await
+it('test', () => {
+ userEvent.click(button);
+ expect(screen.getByText(/success/i)).toBeInTheDocument();
+});
+
+// โ
GOOD - Properly awaited
+it('test', async () => {
+ await userEvent.click(button);
+ expect(await screen.findByText(/success/i)).toBeInTheDocument();
+});
+```
+
+### 2. Testing Implementation Details
+
+```typescript
+// โ BAD - Testing internal state
+expect(component.state.isOpen).toBe(true);
+
+// โ
GOOD - Testing visible behavior
+expect(screen.getByRole('dialog')).toBeVisible();
+```
+
+### 3. Not Cleaning Up
+
+```typescript
+// โ
Always use cleanup
+import { cleanup } from '@testing-library/react';
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+```
+
+---
+
+## ๐ Pre-Merge Checklist
+
+- [ ] All tests passing
+- [ ] Coverage >= 80%
+- [ ] No skipped tests (it.skip)
+- [ ] No focused tests (it.only)
+- [ ] Accessible queries used
+- [ ] userEvent for interactions
+- [ ] Async operations properly awaited
+- [ ] Accessibility tested
+- [ ] Mocks cleaned up
diff --git a/.github/sonarqube_mcp.instructions.md b/.github/sonarqube_mcp.instructions.md
new file mode 100644
index 0000000..1e17f37
--- /dev/null
+++ b/.github/sonarqube_mcp.instructions.md
@@ -0,0 +1,50 @@
+---
+applyTo: '**/*'
+---
+
+These are some guidelines when using the SonarQube MCP server.
+
+# Important Tool Guidelines
+
+## Basic usage
+
+- **IMPORTANT**: After you finish generating or modifying any code files at the very end of the task, you MUST call the `analyze_file_list` tool (if it exists) to analyze the files you created or modified.
+- **IMPORTANT**: When starting a new task, you MUST disable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
+- **IMPORTANT**: When you are done generating code at the very end of the task, you MUST re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
+
+## Project Keys
+
+- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key
+- Don't guess project keys - always look them up
+
+## Code Language Detection
+
+- When analyzing code snippets, try to detect the programming language from the code syntax
+- If unclear, ask the user or make an educated guess based on syntax
+
+## Branch and Pull Request Context
+
+- Many operations support branch-specific analysis
+- If user mentions working on a feature branch, include the branch parameter
+
+## Code Issues and Violations
+
+- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates
+
+# Common Troubleshooting
+
+## Authentication Issues
+
+- SonarQube requires USER tokens (not project tokens)
+- When the error `SonarQube answered with Not authorized` occurs, verify the token type
+
+## Project Not Found
+
+- Use `search_my_sonarqube_projects` to find available projects
+- Verify project key spelling and format
+
+## Code Analysis Issues
+
+- Ensure programming language is correctly specified
+- Remind users that snippet analysis doesn't replace full project scans
+- Provide full file content for better analysis results
diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
index fc872ed..c8ac0d3 100644
--- a/.github/workflows/pr-validation.yml
+++ b/.github/workflows/pr-validation.yml
@@ -19,7 +19,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 22
cache: npm
- name: Install
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 0f62c1a..ffe4408 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -2,52 +2,81 @@ name: Publish to NPM
on:
push:
- tags:
- - 'v*.*.*'
+ branches:
+ - master
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
-
permissions:
contents: read
packages: write
+ id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- - name: Check if tag is from master
- id: check_tag
+ - name: Validate version tag and package.json
run: |
- BRANCH=$(git branch -r --contains ${{ github.sha }} | grep 'origin/master' || true)
- if [ -z "$BRANCH" ]; then
- echo "Tag was not created from master. Skipping publish."
+ PKG_VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
+ TAG="v${PKG_VERSION}"
+
+ if [[ -z "$PKG_VERSION" ]]; then
+ echo "โ ERROR: Could not read version from package.json"
+ exit 1
+ fi
+
+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "โ ERROR: Invalid version format in package.json: '$PKG_VERSION'"
+ echo "Expected format: x.y.z (e.g., 1.0.0, 0.2.3)"
exit 1
fi
+ if ! git rev-parse "$TAG" >/dev/null 2>&1; then
+ echo "โ ERROR: Tag $TAG not found!"
+ echo ""
+ echo "This typically happens when:"
+ echo " 1. You forgot to run 'npm version patch|minor|major' on your feature branch"
+ echo " 2. You didn't push the tag: git push origin
--tags"
+ echo " 3. The tag was created locally but never pushed to remote"
+ echo ""
+ echo "๐ Correct workflow:"
+ echo " 1. On feat/** or feature/**: npm version patch (or minor/major)"
+ echo " 2. Push branch + tag: git push origin feat/your-feature --tags"
+ echo " 3. PR feat/** โ develop, then PR develop โ master"
+ echo " 4. Workflow automatically triggers on master push"
+ echo ""
+ exit 1
+ fi
+
+ echo "โ
package.json version: $PKG_VERSION"
+ echo "โ
Tag $TAG exists in repo"
+ echo "TAG_VERSION=$TAG" >> $GITHUB_ENV
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: '22'
registry-url: 'https://registry.npmjs.org'
+ cache: 'npm'
- name: Install dependencies
run: npm ci
- - name: Run lint (if present)
- run: npm run lint --if-present
- continue-on-error: false
+ - name: Build
+ run: npm run build --if-present
- - name: Run tests (if present)
- run: npm test --if-present
- continue-on-error: false
+ - name: Lint
+ run: npm run lint --if-present 2>/dev/null || true
- - name: Build package
- run: npm run build
+ - name: Test
+ run: npm test --if-present 2>/dev/null || true
- name: Publish to NPM
- run: npm publish --access public
+ run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml
index 1fb9ada..b3ef1a8 100644
--- a/.github/workflows/release-check.yml
+++ b/.github/workflows/release-check.yml
@@ -3,16 +3,6 @@ name: CI - Release Check
on:
pull_request:
branches: [master]
- workflow_dispatch:
- inputs:
- sonar:
- description: 'Run SonarCloud analysis'
- required: true
- default: 'false'
- type: choice
- options:
- - 'false'
- - 'true'
concurrency:
group: ci-release-${{ github.ref }}
@@ -24,11 +14,13 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 25
+ permissions:
+ contents: read
# Config stays in the workflow file (token stays in repo secrets)
env:
SONAR_HOST_URL: 'https://sonarcloud.io'
SONAR_ORGANIZATION: 'ciscode'
- SONAR_PROJECT_KEY: 'CISCODE-MA_WidgetKit-UI'
+ SONAR_PROJECT_KEY: 'CISCODE-MA_NotificationKit-UI'
steps:
- name: Checkout
@@ -61,21 +53,20 @@ jobs:
run: npm run build
- name: SonarCloud Scan
- if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }}
uses: SonarSource/sonarqube-scan-action@v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }}
with:
args: >
- -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} \
- -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} \
- -Dsonar.sources=src \
- -Dsonar.tests=test \
+ -Dsonar.organization=${{ env.SONAR_ORGANIZATION }}
+ -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }}
+ -Dsonar.sources=src
+ -Dsonar.tests=src/__tests__
+ -Dsonar.exclusions=src/__tests__/**
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
- name: SonarCloud Quality Gate
- if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }}
uses: SonarSource/sonarqube-quality-gate-action@v1
timeout-minutes: 10
env:
diff --git a/.prettierrc.json b/.prettierrc.json
index 47174e4..5821380 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -2,5 +2,10 @@
"semi": true,
"singleQuote": true,
"trailingComma": "all",
- "printWidth": 100
+ "printWidth": 100,
+ "tabWidth": 2,
+ "useTabs": false,
+ "arrowParens": "always",
+ "bracketSpacing": true,
+ "endOfLine": "lf"
}
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/eslint.config.js b/eslint.config.js
index 7fb4490..c618efd 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -6,11 +6,10 @@ import prettier from 'eslint-config-prettier';
export default [
{
- ignores: ['dist/**', 'node_modules/**', 'coverage/**', '.vitest/**'],
+ ignores: ['dist/**', '*.d.ts', 'node_modules/**', 'coverage/**', '.vitest/**', 'build/**'],
},
js.configs.recommended,
-
...tseslint.configs.recommended,
{
@@ -29,32 +28,12 @@ export default [
},
rules: {
...reactHooks.configs.recommended.rules,
-
- // modern React: no need for React import in scope
'react/react-in-jsx-scope': 'off',
- },
- },
- {
- files: ['src/utils/**/*.{ts,tsx}'],
- rules: {
- 'no-restricted-imports': [
- 'error',
- {
- paths: [
- {
- name: 'react',
- message: 'utils must not import react. Move code to hooks/components.',
- },
- {
- name: 'react-dom',
- message: 'utils must not import react-dom. Move code to hooks/components.',
- },
- ],
- },
- ],
+ 'react/prop-types': 'off',
+ '@typescript-eslint/no-explicit-any': 'warn',
+ '@typescript-eslint/no-unused-vars': 'warn',
},
},
- // must be last: turns off rules that conflict with prettier
prettier,
];
diff --git a/package-lock.json b/package-lock.json
index c4a7fb9..c5e7e4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,8 @@
"@eslint/js": "^9.39.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "@vitest/coverage-v8": "^2.1.8",
+ "autoprefixer": "^10.4.24",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
@@ -24,8 +26,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 +57,33 @@
"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/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@asamuzakjp/css-color": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
@@ -367,6 +399,13 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -1514,6 +1553,106 @@
"node": "20 || >=22"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@isaacs/cliui/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1674,6 +1813,17 @@
"node": ">= 8"
}
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
@@ -2382,6 +2532,39 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz",
+ "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^0.2.3",
+ "debug": "^4.3.7",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.12",
+ "magicast": "^0.3.5",
+ "std-env": "^3.8.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "2.1.9",
+ "vitest": "2.1.9"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
@@ -2614,6 +2797,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 +3002,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 +3095,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 +3252,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 +3498,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 +3700,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 +3720,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",
@@ -3470,6 +3761,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -4352,6 +4650,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "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",
@@ -4752,6 +5081,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -4958,6 +5294,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",
@@ -5366,30 +5715,110 @@
"dev": true,
"license": "ISC"
},
- "node_modules/iterator.prototype": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
- "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "define-data-property": "^1.1.4",
- "es-object-atoms": "^1.0.0",
- "get-intrinsic": "^1.2.6",
- "get-proto": "^1.0.0",
- "has-symbols": "^1.1.0",
- "set-function-name": "^2.0.2"
- },
+ "license": "BSD-3-Clause",
"engines": {
- "node": ">= 0.4"
+ "node": ">=8"
}
},
- "node_modules/joycon": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
- "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
- "license": "MIT",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
+ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.23",
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
+ "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "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",
+ "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
+ "dev": true,
+ "license": "MIT",
"engines": {
"node": ">=10"
}
@@ -5786,6 +6215,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
+ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.4",
+ "@babel/types": "^7.25.4",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5970,6 +6427,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 +6476,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 +6922,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 +7030,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 +7228,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",
@@ -7309,6 +7918,39 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/string-width/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -7449,6 +8091,20 @@
"node": ">=8"
}
},
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -7563,6 +8219,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",
@@ -7576,6 +8362,139 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/test-exclude": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
+ "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^10.4.1",
+ "minimatch": "^10.2.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/test-exclude/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/test-exclude/node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/test-exclude/node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
+ "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/test-exclude/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/test-exclude/node_modules/minimatch": {
+ "version": "10.2.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/test-exclude/node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -8058,6 +8977,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",
@@ -8849,6 +9775,73 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrap-ansi/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
diff --git a/package.json b/package.json
index 7de374e..16a7c92 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,28 +45,36 @@
"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"
+ "vitest": "^2.1.8",
+ "@vitest/coverage-v8": "^2.1.8"
},
"scripts": {
"clean": "rimraf dist *.tsbuildinfo",
- "build": "tsup",
+ "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",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
+ "test:cov": "vitest run --coverage",
"format": "prettier . --check",
"format:write": "prettier . --write",
+ "verify": "npm run lint && npm run typecheck && npm run test:cov",
+ "prepublishOnly": "npm run verify && npm run build",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "changeset publish",
- "prepare": "husky",
- "lint": "eslint .",
- "lint:fix": "eslint . --fix"
+ "prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx,js,jsx,mjs,cjs,json,md,yml,yaml}": [
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__/NotificationActionList.test.tsx b/src/__tests__/NotificationActionList.test.tsx
new file mode 100644
index 0000000..9d07fb8
--- /dev/null
+++ b/src/__tests__/NotificationActionList.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { NotificationActionList } from '../components/NotificationActionList.js';
+
+describe('NotificationActionList', () => {
+ it('renders action buttons with correct labels', () => {
+ const actions = [
+ { label: 'Undo', onClick: vi.fn() },
+ { label: 'Retry', onClick: vi.fn() },
+ ];
+ render();
+
+ expect(screen.getByText('Undo')).toBeInTheDocument();
+ expect(screen.getByText('Retry')).toBeInTheDocument();
+ });
+
+ it('calls onClick when action button is clicked', () => {
+ const onClick = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByText('Confirm'));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders nothing when actions array is empty', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('button')).toHaveLength(0);
+ });
+});
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__/NotificationItem.test.tsx b/src/__tests__/NotificationItem.test.tsx
new file mode 100644
index 0000000..88a0685
--- /dev/null
+++ b/src/__tests__/NotificationItem.test.tsx
@@ -0,0 +1,98 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { NotificationItem } from '../components/NotificationItem.js';
+import type { NotificationRecord } from '../models/index.js';
+
+function makeItem(overrides: Partial = {}): NotificationRecord {
+ return {
+ id: 'test-id',
+ title: 'Test title',
+ message: 'Test message',
+ type: 'info',
+ position: 'top-right',
+ animation: { type: 'slide', durationMs: 200 },
+ autoDismiss: false,
+ durationMs: 3000,
+ pauseOnHover: false,
+ pauseOnFocus: false,
+ closeButton: true,
+ clearOnNavigate: false,
+ ariaRole: 'status',
+ createdAt: Date.now(),
+ state: 'visible',
+ ...overrides,
+ };
+}
+
+describe('NotificationItem', () => {
+ it('renders title and message', () => {
+ render();
+ expect(screen.getByText('Test title')).toBeInTheDocument();
+ expect(screen.getByText('Test message')).toBeInTheDocument();
+ });
+
+ it('calls onDismiss when close button is clicked', () => {
+ const onDismiss = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
+ expect(onDismiss).toHaveBeenCalledWith('test-id');
+ });
+
+ it('calls onDismiss when Escape key is pressed', () => {
+ const onDismiss = vi.fn();
+ const { container } = render();
+ fireEvent.keyDown(container.firstChild as Element, { key: 'Escape' });
+ expect(onDismiss).toHaveBeenCalledWith('test-id');
+ });
+
+ it('calls item.onClick when clicked', () => {
+ const onClick = vi.fn();
+ const { container } = render(
+ ,
+ );
+ fireEvent.click(container.firstChild as Element);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('pauses timer on mouse enter and resumes on mouse leave', () => {
+ const item = makeItem({ autoDismiss: true, durationMs: 5000, pauseOnHover: true });
+ const { container } = render();
+ const el = container.firstChild as Element;
+ fireEvent.mouseEnter(el);
+ fireEvent.mouseLeave(el);
+ // No assertion needed โ just verifying handlePause/handleResume don't throw
+ });
+
+ it('pauses on focus and resumes on blur', () => {
+ const item = makeItem({ autoDismiss: true, durationMs: 5000, pauseOnFocus: true });
+ const { container } = render();
+ const el = container.firstChild as Element;
+ fireEvent.focus(el);
+ fireEvent.blur(el);
+ });
+
+ it('renders with fade animation class', () => {
+ const item = makeItem({ animation: { type: 'fade', durationMs: 200 } });
+ const { container } = render();
+ expect((container.firstChild as Element).className).toContain('animate-notify-fade');
+ });
+
+ it('renders with scale animation class', () => {
+ const item = makeItem({ animation: { type: 'scale', durationMs: 200 } });
+ const { container } = render();
+ expect((container.firstChild as Element).className).toContain('animate-notify-scale');
+ });
+
+ it('renders actions when provided', () => {
+ const item = makeItem({ actions: [{ label: 'Undo', onClick: vi.fn() }] });
+ render();
+ expect(screen.getByText('Undo')).toBeInTheDocument();
+ });
+
+ it('renders progress bar when autoDismiss is true', () => {
+ const item = makeItem({ autoDismiss: true, durationMs: 3000 });
+ const { container } = render();
+ // NotificationProgress renders a progress element
+ expect(container.querySelector('[role="progressbar"], .progress, div[style]')).toBeDefined();
+ });
+});
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__/hooks.test.tsx b/src/__tests__/hooks.test.tsx
new file mode 100644
index 0000000..cb9e30e
--- /dev/null
+++ b/src/__tests__/hooks.test.tsx
@@ -0,0 +1,137 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { useRef } from 'react';
+import { useClearOnNavigate } from '../hooks/useClearOnNavigate.js';
+import { useFocusTrap, useLiveRegion } from '../hooks/useAccessibility.js';
+import { useNotification } from '../hooks/useNotification.js';
+
+// โโโ useClearOnNavigate โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe('useClearOnNavigate', () => {
+ it('calls clearAll when locationKey changes', () => {
+ const clearAll = vi.fn();
+ const { rerender } = renderHook(
+ ({ key }: { key: string }) => useClearOnNavigate(clearAll, key),
+ { initialProps: { key: 'route-1' } },
+ );
+
+ expect(clearAll).toHaveBeenCalledTimes(1); // initial effect run
+
+ rerender({ key: 'route-2' });
+ expect(clearAll).toHaveBeenCalledTimes(2);
+
+ rerender({ key: 'route-2' }); // same key โ should not re-fire
+ expect(clearAll).toHaveBeenCalledTimes(2);
+ });
+});
+
+// โโโ useFocusTrap โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe('useFocusTrap', () => {
+ it('attaches and detaches keydown listener on the container', () => {
+ const div = document.createElement('div');
+ document.body.appendChild(div);
+
+ const addSpy = vi.spyOn(div, 'addEventListener');
+ const removeSpy = vi.spyOn(div, 'removeEventListener');
+
+ const { unmount } = renderHook(() => {
+ const ref = useRef(div as HTMLElement);
+ useFocusTrap(ref);
+ });
+
+ expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
+
+ unmount();
+ expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
+
+ document.body.removeChild(div);
+ });
+
+ it('wraps focus to last element when Tab is pressed on first focusable element', () => {
+ const container = document.createElement('div');
+ const btn1 = document.createElement('button');
+ const btn2 = document.createElement('button');
+ container.appendChild(btn1);
+ container.appendChild(btn2);
+ document.body.appendChild(container);
+ btn1.focus();
+
+ renderHook(() => {
+ const ref = useRef(container as HTMLElement);
+ useFocusTrap(ref);
+ });
+
+ // Tab forward from last element should wrap to first
+ btn2.focus();
+ const tabEvent = new KeyboardEvent('keydown', {
+ key: 'Tab',
+ bubbles: true,
+ cancelable: true,
+ });
+ act(() => {
+ container.dispatchEvent(tabEvent);
+ });
+
+ document.body.removeChild(container);
+ });
+
+ it('does nothing when ref is null', () => {
+ // Should not throw
+ expect(() => {
+ renderHook(() => {
+ const ref = useRef(null);
+ useFocusTrap(ref);
+ });
+ }).not.toThrow();
+ });
+});
+
+// โโโ useLiveRegion โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe('useLiveRegion', () => {
+ it('creates a live region element with the given message', () => {
+ renderHook(() => useLiveRegion('Notification sent'));
+
+ const region = document.querySelector('[data-notification-live-region="true"]');
+ expect(region).not.toBeNull();
+ expect(region?.textContent).toBe('Notification sent');
+ });
+
+ it('reuses an existing live region element', () => {
+ renderHook(() => useLiveRegion('First message'));
+ renderHook(() => useLiveRegion('Second message'));
+
+ const regions = document.querySelectorAll('[data-notification-live-region="true"]');
+ expect(regions.length).toBe(1);
+ });
+
+ it('skips creation when message is empty', () => {
+ document
+ .querySelectorAll('[data-notification-live-region="true"]')
+ .forEach((el) => el.remove());
+
+ renderHook(() => useLiveRegion(''));
+
+ const region = document.querySelector('[data-notification-live-region="true"]');
+ expect(region).toBeNull();
+ });
+
+ it('supports polite priority', () => {
+ renderHook(() => useLiveRegion('Polite message', 'polite'));
+
+ const region = document.querySelector('[data-priority="polite"]');
+ expect(region).not.toBeNull();
+ expect(region?.getAttribute('aria-live')).toBe('polite');
+ });
+});
+
+// โโโ useNotification (error path) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe('useNotification', () => {
+ it('throws when used outside of NotificationProvider', () => {
+ expect(() => {
+ renderHook(() => useNotification());
+ }).toThrow('useNotification must be used within a NotificationProvider');
+ });
+});
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/NotificationActionList.tsx b/src/components/NotificationActionList.tsx
new file mode 100644
index 0000000..2869d94
--- /dev/null
+++ b/src/components/NotificationActionList.tsx
@@ -0,0 +1,18 @@
+import type { NotificationAction } from '../models/index.js';
+
+export function NotificationActionList({ actions }: { actions: NotificationAction[] }) {
+ return (
+
+ {actions.map((action, index) => (
+
+ ))}
+
+ );
+}
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],
+};
diff --git a/vitest.config.ts b/vitest.config.ts
index c331199..0d789db 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -2,13 +2,13 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
- testDir: 'src/__tests__',
+ dir: 'src/__tests__',
environment: 'jsdom',
setupFiles: ['src/__tests__/setup.ts'],
exclude: ['node_modules/**', 'tests/e2e/**', 'dist/**'],
coverage: {
provider: 'v8',
- reporter: ['text', 'html', 'json'],
+ reporter: ['text', 'html', 'json', 'lcov'],
reportsDirectory: 'coverage',
exclude: ['src/components/Dashboard/**', 'src/layout/**', 'src/main/**'],
thresholds: {