+```
+
+---
+
+## βΏ 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/publish.yml b/.github/workflows/publish.yml
index 0f62c1a..4c06372 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -2,52 +2,92 @@ 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."
+ # Since developβmaster may be a squash merge, look for the latest version tag anywhere in the repo
+ # This handles both regular merges and squash merges
+ TAG=$(git tag --list --sort=-version:refname 'v*.*.*' | head -1 || echo "")
+
+ if [[ -z "$TAG" ]]; then
+ echo "β ERROR: No version tag found!"
+ echo ""
+ echo "This typically happens when:"
+ echo " 1. You forgot to run 'npm version patch|minor|major' on develop"
+ echo " 2. You didn't push tags: git push origin develop --tags"
+ echo " 3. Tags weren't pushed to GitHub before merge"
+ echo ""
+ echo "π Correct workflow:"
+ echo " 1. On develop: npm version patch (or minor/major)"
+ echo " 2. On develop: git push origin develop --tags"
+ echo " 3. Create PR developβmaster and merge (can be squash merge)"
+ echo " 4. Workflow automatically triggers on master with the tag"
+ echo ""
+ exit 1
+ fi
+
+ # Validate tag format
+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "β ERROR: Invalid tag format: '$TAG'"
+ echo "Expected format: v*.*.* (e.g., v1.0.0, v0.2.3)"
exit 1
fi
+ # Extract version from tag
+ TAG_VERSION="${TAG#v}" # Remove 'v' prefix
+ PKG_VERSION=$(grep '"version"' package.json | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
+
+ # Verify package.json version matches tag
+ if [[ "$TAG_VERSION" != "$PKG_VERSION" ]]; then
+ echo "β ERROR: Version mismatch!"
+ echo " Tag version: $TAG_VERSION"
+ echo " package.json: $PKG_VERSION"
+ echo ""
+ echo "Fix: Make sure you ran 'npm version' before pushing"
+ exit 1
+ fi
+
+ echo "β
Valid tag found: $TAG"
+ echo "β
Version matches package.json: $PKG_VERSION"
+ echo "TAG_VERSION=$TAG" >> $GITHUB_ENV
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
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..72e7140 100644
--- a/.github/workflows/release-check.yml
+++ b/.github/workflows/release-check.yml
@@ -2,7 +2,7 @@ name: CI - Release Check
on:
pull_request:
- branches: [master]
+ branches: [master, main]
workflow_dispatch:
inputs:
sonar:
@@ -24,11 +24,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
@@ -62,7 +64,7 @@ jobs:
- name: SonarCloud Scan
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }}
- uses: SonarSource/sonarqube-scan-action@v6
+ uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 # v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }}
@@ -76,7 +78,7 @@ jobs:
- name: SonarCloud Quality Gate
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }}
- uses: SonarSource/sonarqube-quality-gate-action@v1
+ uses: SonarSource/sonarqube-quality-gate-action@d304d050d930b02a896b0f85935344f023928496 # v1
timeout-minutes: 10
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
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..1eb0e91 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@eslint/js": "^9.39.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "autoprefixer": "^10.4.24",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
@@ -24,8 +25,11 @@
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"lint-staged": "^15.2.10",
+ "postcss": "^8.5.6",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
+ "tailwindcss": "^3.4.19",
+ "tailwindcss-rtl": "^0.9.0",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"typescript-eslint": "^8.50.1",
@@ -52,6 +56,19 @@
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
"license": "MIT"
},
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/@asamuzakjp/css-color": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
@@ -2614,6 +2631,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2798,6 +2836,43 @@
"node": ">= 0.4"
}
},
+ "node_modules/autoprefixer": {
+ "version": "10.4.27",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001774",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2854,6 +2929,19 @@
"require-from-string": "^2.0.2"
}
},
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2998,10 +3086,20 @@
"node": ">=6"
}
},
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/caniuse-lite": {
- "version": "1.0.30001761",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz",
- "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==",
+ "version": "1.0.30001777",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
+ "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
"dev": true,
"funding": [
{
@@ -3234,6 +3332,19 @@
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"license": "MIT"
},
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/cssstyle": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz",
@@ -3423,6 +3534,13 @@
"node": ">=8"
}
},
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -3436,6 +3554,13 @@
"node": ">=8"
}
},
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -4352,6 +4477,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
"node_modules/fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
@@ -4958,6 +5097,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-boolean-object": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
@@ -5384,6 +5536,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@@ -5970,6 +6132,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
@@ -6009,6 +6181,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -6445,6 +6627,71 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-import/node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
"node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
@@ -6488,6 +6735,53 @@
}
}
},
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6639,6 +6933,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/read-cache/node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/read-yaml-file": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz",
@@ -7563,6 +7877,136 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss-rtl": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz",
+ "integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss/node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/term-size": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz",
@@ -8058,6 +8502,13 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/package.json b/package.json
index 7de374e..dbe4fda 100644
--- a/package.json
+++ b/package.json
@@ -17,12 +17,13 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
- "import": "./dist/index.mjs",
+ "import": "./dist/index.js",
"require": "./dist/index.cjs"
- }
+ },
+ "./style.css": "./dist/style.css"
},
"main": "./dist/index.cjs",
- "module": "./dist/index.mjs",
+ "module": "./dist/index.js",
"types": "./dist/index.d.ts",
"publishConfig": {
"access": "public"
@@ -36,6 +37,7 @@
"@eslint/js": "^9.39.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "autoprefixer": "^10.4.24",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
@@ -43,8 +45,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,19 +57,23 @@
},
"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__/NotificationItem.a11y.test.tsx b/src/__tests__/NotificationItem.a11y.test.tsx
new file mode 100644
index 0000000..9610f65
--- /dev/null
+++ b/src/__tests__/NotificationItem.a11y.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { NotificationItem } from '../components/NotificationItem.js';
+import React from 'react';
+
+describe('NotificationItem accessibility', () => {
+ it('has proper ARIA attributes', () => {
+ render(
+
{}}
+ />,
+ );
+ const notification = screen.getByRole('status');
+ expect(notification).toHaveAttribute('aria-describedby');
+ expect(screen.getByText('Accessible')).toBeInTheDocument();
+ expect(screen.getByText('Notification')).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/NotificationProvider.test.tsx b/src/__tests__/NotificationProvider.test.tsx
new file mode 100644
index 0000000..ae72d04
--- /dev/null
+++ b/src/__tests__/NotificationProvider.test.tsx
@@ -0,0 +1,89 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { NotificationProvider } from '../components/NotificationProvider.js';
+import { useNotification } from '../hooks/useNotification.js';
+import React from 'react';
+
+describe('NotificationProvider', () => {
+ function TestComponent() {
+ const { notify, dismiss, state } = useNotification();
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ it('renders notifications and allows dismiss', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('Show'));
+ expect(screen.getByText('Test')).toBeInTheDocument();
+ expect(screen.getByText('Hello')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('Dismiss'));
+ expect(screen.queryByText('Test')).not.toBeInTheDocument();
+ });
+
+ it('clears only clearOnNavigate notifications when navigationKey changes', () => {
+ function Wrapper() {
+ const [route, setRoute] = React.useState(1);
+
+ return (
+
+ setRoute((prev) => prev + 1)} />
+
+ );
+ }
+
+ function RouteAwareTest({ onNavigate }: { onNavigate: () => void }) {
+ const { notify } = useNotification();
+
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+
+ render();
+
+ fireEvent.click(screen.getByText('Add transient'));
+ fireEvent.click(screen.getByText('Add persistent'));
+
+ expect(screen.getByText('Transient')).toBeInTheDocument();
+ expect(screen.getByText('Persistent')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Navigate'));
+
+ expect(screen.queryByText('Transient')).not.toBeInTheDocument();
+ expect(screen.getByText('Persistent')).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/notificationStore.test.ts b/src/__tests__/notificationStore.test.ts
new file mode 100644
index 0000000..4409709
--- /dev/null
+++ b/src/__tests__/notificationStore.test.ts
@@ -0,0 +1,65 @@
+import { NotificationStore } from '../store/notificationStore.js';
+import { describe, expect, it } from 'vitest';
+
+describe('NotificationStore', () => {
+ it('adds notifications and enforces maxVisible', () => {
+ const store = new NotificationStore({ maxVisible: 2 });
+ store.add({ title: 'First' });
+ store.add({ title: 'Second' });
+ store.add({ title: 'Third' });
+ const state = store.getState();
+ expect(state.notifications.length).toBe(2);
+ expect(state.notifications[0].title).toBe('Second');
+ expect(state.notifications[1].title).toBe('Third');
+ });
+
+ it('moves overflowed notifications to history', () => {
+ const store = new NotificationStore({ maxVisible: 2 });
+ store.add({ title: 'First' });
+ store.add({ title: 'Second' });
+ store.add({ title: 'Third' });
+
+ const state = store.getState();
+ expect(state.history.length).toBe(1);
+ expect(state.history[0].title).toBe('First');
+ });
+
+ it('dismisses notifications and adds to history', () => {
+ const store = new NotificationStore({ maxVisible: 2 });
+ const n1 = store.add({ title: 'A' });
+ store.dismiss(n1.id);
+ const state = store.getState();
+ expect(state.notifications.length).toBe(0);
+ expect(state.history.length).toBe(1);
+ expect(state.history[0].title).toBe('A');
+ });
+
+ it('clears all notifications and moves them to history', () => {
+ const store = new NotificationStore({ maxVisible: 2 });
+ store.add({ title: 'A' });
+ store.add({ title: 'B' });
+ store.clearAll();
+ const state = store.getState();
+ expect(state.notifications.length).toBe(0);
+ expect(state.history.length).toBe(2);
+ });
+
+ it('restores notifications from history', () => {
+ const store = new NotificationStore({ maxVisible: 2 });
+ const n1 = store.add({ title: 'A' });
+ store.dismiss(n1.id);
+ store.restore(n1.id);
+ const state = store.getState();
+ expect(state.notifications.length).toBe(1);
+ expect(state.notifications[0].title).toBe('A');
+ expect(state.history.length).toBe(0);
+ });
+
+ it('updates notifications by id', () => {
+ const store = new NotificationStore();
+ const n1 = store.add({ title: 'A', message: 'Old' });
+ store.update({ id: n1.id, message: 'New' });
+ const state = store.getState();
+ expect(state.notifications[0].message).toBe('New');
+ });
+});
diff --git a/src/assets/styles/style.css b/src/assets/styles/style.css
new file mode 100644
index 0000000..7467a29
--- /dev/null
+++ b/src/assets/styles/style.css
@@ -0,0 +1,18 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Accessibility: Screen reader only class */
+@layer utilities {
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+ }
+}
diff --git a/src/components/NoopButton.tsx b/src/components/NoopButton.tsx
deleted file mode 100644
index 4bee846..0000000
--- a/src/components/NoopButton.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { ButtonHTMLAttributes } from 'react';
-import { useNoop } from '../hooks';
-
-export type NoopButtonProps = ButtonHTMLAttributes;
-
-export function NoopButton(props: NoopButtonProps) {
- const onClick = useNoop();
- return ;
-}
diff --git a/src/components/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],
+};