diff --git a/.changeset/bright-owls-approve.md b/.changeset/bright-owls-approve.md new file mode 100644 index 0000000..9bad333 --- /dev/null +++ b/.changeset/bright-owls-approve.md @@ -0,0 +1,27 @@ +--- +'@ciscode/ui-notification-kit': minor +--- + +Ship the first functional release of NotificationKit-UI. + +### Added + +- Notification provider and hook API (`NotificationProvider`, `useNotification`) +- Notification types: success, error, warning, info, loading, default +- Position support: top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right +- Configurable animations (slide, fade, scale), durations, auto-dismiss, close button, actions, and custom icons +- Store lifecycle with add/update/dismiss/clear/restore and history tracking +- Route-aware clearing support (`clearOnNavigate`, `navigationKey`) +- Accessibility support (live-region announcements, ARIA roles, keyboard escape-to-dismiss) +- Tailwind + RTL styling support and published style asset export (`./style.css`) +- Test coverage for store behavior, provider behavior, and a11y essentials + +### Changed + +- Package entry exports updated to align with generated build outputs +- Notification rendering moved to a portal to avoid stacking-context issues in host apps +- Layering hardened so notifications stay above dashboard content + +### Notes + +- Import styles in host apps using: `@ciscode/ui-notification-kit/style.css` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fc84ee6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,193 @@ +# Copilot Instructions - NotificationKit-UI + +> **Purpose**: Development guidelines for NotificationKit-UI - reusable React notification UI components. + +--- + +## ๐ŸŽฏ Package Overview + +**Package**: `@ciscode/ui-notification-kit` +**Type**: React Frontend Component Library +**Purpose**: Pre-built notification and alert UI components for React apps + +### This Package Provides: + +- Notification display components +- Toast/alert components +- Notification context providers +- Notification state management hooks +- TypeScript types for notification state +- Vitest unit tests with 80%+ coverage +- Changesets for version management +- Husky + lint-staged for code quality + +--- + +## ๐Ÿ—๏ธ Project Structure + +``` +src/ + โ”œโ”€โ”€ components/ # React components + โ”‚ โ”œโ”€โ”€ Notification/ + โ”‚ โ”œโ”€โ”€ Toast/ + โ”‚ โ””โ”€โ”€ index.ts + โ”œโ”€โ”€ hooks/ # Custom hooks + โ”‚ โ”œโ”€โ”€ useNotification.ts + โ”‚ โ””โ”€โ”€ useToast.ts + โ”œโ”€โ”€ context/ # Context providers + โ”‚ โ””โ”€โ”€ NotificationProvider.tsx + โ”œโ”€โ”€ types/ # TypeScript types + โ”‚ โ””โ”€โ”€ notification.types.ts + โ””โ”€โ”€ index.ts # Public exports +``` + +--- + +## ๐Ÿ“ Naming Conventions + +**Components**: `PascalCase.tsx` + +- `Notification.tsx` +- `Toast.tsx` +- `Alert.tsx` + +**Hooks**: `camelCase.ts` with `use` prefix + +- `useNotification.ts` +- `useToast.ts` + +**Types**: `kebab-case.ts` + +- `notification.types.ts` + +--- + +## ๐Ÿงช Testing Standards + +### Coverage Target: 80%+ + +**Unit Tests:** + +- โœ… All components +- โœ… All custom hooks +- โœ… Context logic +- โœ… Type definitions + +**Component Tests:** + +- โœ… Rendering checks +- โœ… User interactions +- โœ… State changes + +**Test file location:** + +``` +Notification/ + โ”œโ”€โ”€ Notification.tsx + โ””โ”€โ”€ Notification.test.tsx +``` + +--- + +## ๐Ÿ“š Documentation + +### JSDoc Required For: + +- All exported components +- All exported hooks +- All exported types/interfaces +- All public functions + +### Example: + +```typescript +/** + * Displays a notification message + * @param message - The notification message + * @param type - Type of notification (success, error, warning, info) + * @returns Notification component + */ +export function Notification({ message, type }: Props): JSX.Element; +``` + +--- + +## ๐ŸŽจ Code Style + +- ESLint with TypeScript support (`--max-warnings=0`) +- Prettier formatting +- TypeScript strict mode +- Functional components only +- No `React.FC` - always explicit `JSX.Element` return type + +--- + +## ๐Ÿ”„ Development Workflow + +### Branch Naming: + +```bash +feature/NK-UI-123-add-notification +bugfix/NK-UI-456-fix-toast-timing +refactor/NK-UI-789-extract-styles +``` + +### Before Publishing: + +- [ ] All tests passing +- [ ] Coverage >= 80% +- [ ] ESLint checks pass +- [ ] TypeScript strict mode passes +- [ ] All public APIs documented +- [ ] Changeset created +- [ ] README updated + +--- + +## ๐Ÿ“ฆ Versioning + +**MAJOR** (x.0.0): Breaking API changes +**MINOR** (0.x.0): New features (backward compatible) +**PATCH** (0.0.x): Bug fixes and improvements + +Always create a changeset for user-facing changes using `npm run changeset`. + +--- + +## ๐Ÿ” Security + +- Never expose sensitive data in notifications +- Sanitize notification content +- Validate all notification props +- No `dangerouslySetInnerHTML` without approval + +--- + +## ๐Ÿšซ Restrictions + +**NEVER without approval:** + +- Breaking changes to component APIs +- Removing exported components/hooks +- Major dependency upgrades + +**CAN do autonomously:** + +- Bug fixes (non-breaking) +- Internal refactoring +- Adding new features (additive) +- Test improvements + +--- + +## ๐Ÿ’ฌ Communication + +- Brief and direct +- Reference component names when discussing changes +- Flag breaking changes immediately +- This package is consumed by multiple applications + +--- + +_Last Updated: March 3, 2026_ +_Version: 0.1.0_ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8d34dee --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: '/' + schedule: + interval: monthly + open-pull-requests-limit: 1 + groups: + npm-dependencies: + patterns: + - '*' + assignees: + - CISCODE-MA/cloud-devops + labels: + - 'dependencies' + - 'npm' + commit-message: + prefix: 'chore(deps)' + include: 'scope' + rebase-strategy: auto diff --git a/.github/instructions/bugfix.instructions.md b/.github/instructions/bugfix.instructions.md new file mode 100644 index 0000000..9c7da05 --- /dev/null +++ b/.github/instructions/bugfix.instructions.md @@ -0,0 +1,275 @@ +# Bugfix Instructions - UI Kit Module + +> **Last Updated**: February 2026 + +--- + +## ๐Ÿ” Bug Investigation Process + +### Phase 1: Reproduce + +**Before writing any code:** + +1. **Understand the issue** - Read bug report carefully +2. **Reproduce locally** - Create minimal reproduction +3. **Verify it's a bug** - Not expected behavior +4. **Check browser compatibility** - Test across browsers + +**Create failing test FIRST:** + +```typescript +describe('Bug: Button not disabled when loading', () => { + it('should disable button during loading', () => { + render(); + + // This SHOULD pass but currently FAILS + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); +``` + +### Phase 2: Identify Root Cause + +**Investigation tools:** + +- **React DevTools** - Inspect component tree +- **Console logs** - Debug state changes +- **Debugger** - Breakpoints in code +- **Browser DevTools** - Check DOM/styles + +```typescript +// Debug component props/state +useEffect(() => { + console.log('Props changed:', props); +}, [props]); +``` + +### Phase 3: Understand Impact + +**Critical questions:** + +- Which browsers affected? +- Does it break accessibility? +- Is there a workaround? +- Does it affect other components? + +--- + +## ๐Ÿ› Common Bug Categories + +### 1. State Management Issues + +| Bug Type | Symptoms | Solution | +| ----------------- | ------------------------- | -------------------------- | +| **Stale closure** | Old values in callback | Update dependencies | +| **Infinite loop** | Component re-renders | Fix useEffect dependencies | +| **Lost state** | State resets unexpectedly | Check component key | + +**Example fix:** + +```typescript +// โŒ BUG - Stale closure +const [count, setCount] = useState(0); + +useEffect(() => { + const timer = setInterval(() => { + setCount(count + 1); // โŒ Always uses initial count + }, 1000); + return () => clearInterval(timer); +}, []); // Missing count dependency + +// โœ… FIX - Functional update +useEffect(() => { + const timer = setInterval(() => { + setCount((prev) => prev + 1); // โœ… Uses current count + }, 1000); + return () => clearInterval(timer); +}, []); +``` + +### 2. useEffect Issues + +| Bug Type | Symptoms | Solution | +| ---------------------- | -------------------- | -------------------- | +| **Memory leak** | Performance degrades | Add cleanup function | +| **Missing cleanup** | Side effects persist | Return cleanup | +| **Wrong dependencies** | Unexpected behavior | Fix dependency array | + +**Example fix:** + +```typescript +// โŒ BUG - No cleanup +useEffect(() => { + const subscription = api.subscribe(handleData); +}, []); + +// โœ… FIX - Cleanup on unmount +useEffect(() => { + const subscription = api.subscribe(handleData); + return () => subscription.unsubscribe(); +}, []); +``` + +### 3. Event Handler Issues + +| Bug Type | Symptoms | Solution | +| ---------------------- | ------------------- | -------------------------- | +| **Handler not called** | Click doesn't work | Check event binding | +| **Multiple calls** | Handler fires twice | Remove duplicate listeners | +| **Wrong event** | Unexpected behavior | Use correct event type | + +**Example fix:** + +```typescript +// โŒ BUG - Handler called immediately + +``` + +--- + +## ๐Ÿ”ง Fix Implementation Process + +### 1. Write Failing Test + +```typescript +it('should fix the bug', async () => { + render(); + + await userEvent.click(screen.getByRole('button')); + + expect(screen.getByText(/expected/i)).toBeInTheDocument(); +}); +``` + +### 2. Implement Fix + +```typescript +// Fix the component +export function Component() { + // Corrected implementation + return
Fixed!
; +} +``` + +### 3. Verify Test Passes + +```bash +npm test -- Component.test.tsx +``` + +### 4. Test in Browser + +```bash +npm run dev +# Manually test the fix +``` + +### 5. Update Documentation + +```typescript +/** + * Component that was buggy + * + * @fixed v1.2.3 - Fixed click handler issue + */ +export function Component(props: Props): JSX.Element; +``` + +--- + +## โš ๏ธ Common Gotchas + +### 1. Prop Mutation + +```typescript +// โŒ Bug - Mutating props +const sortedItems = props.items.sort(); // Mutates! + +// โœ… Fix - Create copy +const sortedItems = [...props.items].sort(); +``` + +### 2. Incorrect Comparison + +```typescript +// โŒ Bug - Object comparison +if (user === prevUser) { +} // Always false (different references) + +// โœ… Fix - Compare values +if (user.id === prevUser.id) { +} +``` + +### 3. Missing Null Checks + +```typescript +// โŒ Bug - No null check +return user.profile.name; // Crashes if profile is null + +// โœ… Fix - Optional chaining +return user?.profile?.name ?? 'Unknown'; +``` + +--- + +## ๐Ÿ“‹ Bugfix Checklist + +- [ ] Bug reproduced in browser +- [ ] Failing test created +- [ ] Root cause identified +- [ ] Fix implemented +- [ ] All tests pass +- [ ] Manually tested in browser +- [ ] Accessibility verified +- [ ] Documentation updated +- [ ] CHANGELOG updated +- [ ] No regression diff --git a/.github/instructions/components.instructions.md b/.github/instructions/components.instructions.md new file mode 100644 index 0000000..836cf94 --- /dev/null +++ b/.github/instructions/components.instructions.md @@ -0,0 +1,408 @@ +# Component Development Instructions - NotificationKit-UI + +> **Purpose**: React component development standards for notification/toast UI components. + +--- + +## ๐ŸŽฏ Component Architecture + +### Component Structure + +``` +ComponentName/ + โ”œโ”€โ”€ ComponentName.tsx # Main component + โ”œโ”€โ”€ ComponentName.test.tsx # Tests + โ”œโ”€โ”€ ComponentName.types.ts # Props & types + โ”œโ”€โ”€ ComponentName.styles.ts # Styled components (if using) + โ””โ”€โ”€ index.ts # Exports +``` + +### Notification Component Template + +```typescript +import React, { useEffect } from 'react'; +import { NotificationProps } from './Notification.types'; + +/** + * Notification/Toast component with auto-dismiss and actions + * @param {NotificationProps} props - Component props + * @returns {JSX.Element} Rendered notification + */ +export const Notification: React.FC = ({ + message, + type = 'info', + duration = 5000, + onClose, + actions, +}) => { + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(onClose, duration); + return () => clearTimeout(timer); + } + }, [duration, onClose]); + + return ( +
+

{message}

+ {actions && ( +
+ {actions.map((action, i) => ( + + ))} +
+ )} + +
+ ); +}; + +Notification.displayName = 'Notification'; +``` + +--- + +## ๐Ÿ“ Props Standards + +### Notification Props Interface + +```typescript +export interface NotificationProps { + /** Notification message content */ + message: string | React.ReactNode; + /** Notification type/severity */ + type?: 'info' | 'success' | 'warning' | 'error'; + /** Auto-dismiss duration in ms (0 = no auto-dismiss) */ + duration?: number; + /** Callback when notification is closed */ + onClose: () => void; + /** Optional action buttons */ + actions?: Array<{ + label: string; + onClick: () => void; + }>; + /** Position on screen */ + position?: + | 'top-right' + | 'top-left' + | 'bottom-right' + | 'bottom-left' + | 'top-center' + | 'bottom-center'; +} +``` + +--- + +## โ™ฟ Accessibility (A11y) + +### ARIA Live Regions + +```typescript +// โœ… Good - Uses aria-live for screen readers +
+ {message} +
+ +// โŒ Bad - No screen reader support +
{message}
+``` + +### Notification ARIA Attributes + +- โœ… `role="status"` or `role="alert"` for notifications +- โœ… `aria-live="polite"` for info/success (non-critical) +- โœ… `aria-live="assertive"` for error/warning (critical) +- โœ… `aria-atomic="true"` to read entire message +- โœ… Close button has `aria-label="Close notification"` + +### Keyboard Support + +```typescript +// Dismiss on Escape key +useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); +}, [onClose]); +``` + +--- + +## ๐ŸŽจ Theming & Styling + +### Notification Types & Colors + +```typescript +const notificationStyles = { + info: { + background: theme.colors.info, + color: theme.colors.infoText, + icon: 'โ„น๏ธ', + }, + success: { + background: theme.colors.success, + color: theme.colors.successText, + icon: 'โœ“', + }, + warning: { + background: theme.colors.warning, + color: theme.colors.warningText, + icon: 'โš ', + }, + error: { + background: theme.colors.error, + color: theme.colors.errorText, + icon: 'โœ•', + }, +}; +``` + +### Animations + +```typescript +// Entry animation +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +// Exit animation +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} +``` + +--- + +## ๐Ÿงช Component Testing + +### Test Coverage Requirements + +```typescript +describe('Notification', () => { + it('renders message', () => { + render(); + expect(screen.getByText('Test notification')).toBeInTheDocument(); + }); + + it('auto-dismisses after duration', async () => { + jest.useFakeTimers(); + const onClose = jest.fn(); + + render(); + + jest.advanceTimersByTime(3000); + expect(onClose).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('calls onClose when close button clicked', async () => { + const onClose = jest.fn(); + render(); + + await userEvent.click(screen.getByLabelText('Close notification')); + expect(onClose).toHaveBeenCalled(); + }); + + it('renders with correct ARIA attributes for errors', () => { + const { container } = render( + + ); + + expect(container.querySelector('[aria-live="assertive"]')).toBeInTheDocument(); + }); + + it('renders action buttons', async () => { + const action = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Undo' })); + expect(action).toHaveBeenCalled(); + }); + + it('does not auto-dismiss when duration is 0', () => { + jest.useFakeTimers(); + const onClose = jest.fn(); + + render(); + + jest.advanceTimersByTime(10000); + expect(onClose).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); +}); +``` + +--- + +## ๐Ÿ”„ State Management + +### Notification Queue Management + +```typescript +interface NotificationState { + id: string; + message: string; + type: NotificationProps['type']; +} + +const [notifications, setNotifications] = useState([]); + +const addNotification = (notification: Omit) => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { ...notification, id }]); +}; + +const removeNotification = (id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); +}; +``` + +### Notification Context Provider + +```typescript +import { createContext, useContext } from 'react'; + +interface NotificationContextValue { + showNotification: (props: NotificationInput) => void; + hideNotification: (id: string) => void; +} + +const NotificationContext = createContext(null); + +export const useNotification = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotification must be used within NotificationProvider'); + } + return context; +}; +``` + +--- + +## ๐Ÿ“ฆ Component Exports + +### Public API (index.ts) + +```typescript +// Export components +export { Notification } from './Notification'; +export { NotificationContainer } from './NotificationContainer'; +export { NotificationProvider, useNotification } from './NotificationContext'; + +// Export types +export type { NotificationProps } from './Notification.types'; +export type { NotificationPosition, NotificationType } from './types'; +``` + +--- + +## ๐Ÿšซ Anti-Patterns to Avoid + +### โŒ Memory Leaks with Timers + +```typescript +// Bad - Timer not cleaned up +useEffect(() => { + setTimeout(onClose, duration); +}, []); + +// Good - Timer cleaned up +useEffect(() => { + const timer = setTimeout(onClose, duration); + return () => clearTimeout(timer); +}, [duration, onClose]); +``` + +### โŒ No Maximum Notifications + +```typescript +// Bad - Unlimited notifications can overflow screen +const addNotification = (notif) => { + setNotifications((prev) => [...prev, notif]); +}; + +// Good - Limit max notifications shown +const MAX_NOTIFICATIONS = 5; +const addNotification = (notif) => { + setNotifications((prev) => [...prev.slice(-MAX_NOTIFICATIONS + 1), notif]); +}; +``` + +### โŒ Blocking UI with Notifications + +```typescript +// Bad - Notifications block content +
+ +// Good - Notifications overlay in corner +
+``` + +--- + +## ๐Ÿ“‹ Pre-Commit Checklist + +- [ ] Notification uses `aria-live` for screen readers +- [ ] Auto-dismiss timer cleaned up properly +- [ ] Close button has accessible label +- [ ] Keyboard support (Escape to dismiss) +- [ ] Max notification limit implemented +- [ ] Entry/exit animations smooth +- [ ] Different types styled distinctly +- [ ] Tests cover auto-dismiss behavior +- [ ] Action buttons work correctly +- [ ] Position prop respected + +--- + +## ๐Ÿ“š Resources + +- [ARIA Live Regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) +- [Toast Accessibility](https://www.w3.org/WAI/ARIA/apg/patterns/alert/) +- [React Toastify](https://fkhadra.github.io/react-toastify/introduction) (inspiration) +- [React Hot Toast](https://react-hot-toast.com/) (inspiration) diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md new file mode 100644 index 0000000..29797c6 --- /dev/null +++ b/.github/instructions/copilot-instructions.md @@ -0,0 +1,286 @@ +# Copilot Instructions - Notification Kit UI Module + +> **Purpose**: Development guidelines for the Notification Kit UI module - reusable React notification components. + +--- + +## ๐ŸŽฏ Module Overview + +**Package**: `@ciscode/ui-notification-kit` +**Type**: React Component Library +**Purpose**: Pre-built notification UI components for React apps + +### Responsibilities: + +- Toast notifications +- Alert banners +- Notification centers +- Badge counters +- Real-time notification displays + +--- + +## ๐Ÿ—๏ธ Module Structure + +``` +src/ + โ”œโ”€โ”€ components/ # React components + โ”‚ โ”œโ”€โ”€ Toast/ + โ”‚ โ”‚ โ”œโ”€โ”€ Toast.tsx + โ”‚ โ”‚ โ”œโ”€โ”€ Toast.test.tsx + โ”‚ โ”‚ โ””โ”€โ”€ index.ts + โ”‚ โ”œโ”€โ”€ NotificationCenter/ + โ”‚ โ””โ”€โ”€ AlertBanner/ + โ”œโ”€โ”€ hooks/ # Custom hooks + โ”‚ โ”œโ”€โ”€ use-notifications.ts + โ”‚ โ””โ”€โ”€ use-toast.ts + โ”œโ”€โ”€ context/ # Notification context provider + โ”‚ โ””โ”€โ”€ NotificationProvider.tsx + โ”œโ”€โ”€ types/ # TypeScript types + โ”‚ โ””โ”€โ”€ notification.types.ts + โ””โ”€โ”€ index.ts # Exports +``` + +--- + +## ๐Ÿ“ Naming Conventions + +**Components**: `PascalCase.tsx` + +- `Toast.tsx` +- `NotificationCenter.tsx` +- `AlertBanner.tsx` + +**Hooks**: `camelCase.ts` with `use` prefix + +- `use-notifications.ts` +- `use-toast.ts` + +**Types**: `kebab-case.ts` + +- `notification.types.ts` + +--- + +## ๐Ÿงช Testing - Component Library Standards + +### Coverage Target: 80%+ + +**Unit Tests:** + +- โœ… All custom hooks +- โœ… Utilities and helpers +- โœ… Context logic + +**Component Tests:** + +- โœ… All components with user interactions +- โœ… Notification display logic +- โœ… Error state handling +- โœ… Auto-dismiss functionality + +**Skip:** + +- โŒ Purely presentational components (no logic) + +**Test location:** + +``` +Toast/ + โ”œโ”€โ”€ Toast.tsx + โ””โ”€โ”€ Toast.test.tsx โ† Same directory +``` + +--- + +## ๐Ÿ“š Documentation Standards + +### JSDoc for Hooks: + +````typescript +/** + * Hook for managing notification state + * @returns Notification methods and state + * @example + * ```tsx + * const { notify, dismiss, notifications } = useNotifications(); + * + * const showSuccess = () => { + * notify({ type: 'success', message: 'Action completed!' }); + * }; + * ``` + */ +export function useNotifications(): UseNotificationsReturn; +```` + +### Component Documentation: + +````typescript +export interface ToastProps { + /** Toast message content */ + message: string; + /** Toast type (success, error, warning, info) */ + type?: 'success' | 'error' | 'warning' | 'info'; + /** Auto-dismiss duration in ms (0 = no auto-dismiss) */ + duration?: number; + /** Callback when toast is dismissed */ + onDismiss?: () => void; +} + +/** + * Toast notification component + * + * @example + * ```tsx + * + * ``` + */ +export function Toast(props: ToastProps): JSX.Element; +```` + +--- + +## ๐Ÿš€ Module Development Principles + +### 1. Headless & Customizable + +**Unstyled by default:** + +```typescript +// Components accept className prop + +``` + +### 2. Accessibility First + +**ARIA support:** + +```tsx +
+ {message} +
+``` + +### 3. TypeScript Strict Mode + +```typescript +// All exports fully typed +export type NotificationType = 'success' | 'error' | 'warning' | 'info'; +``` + +--- + +## ๐Ÿ› ๏ธ Development Workflow + +### Creating New Components: + +1. **Create component folder** + + ``` + mkdir -p src/components/MyComponent + cd src/components/MyComponent + ``` + +2. **Create files** + - `MyComponent.tsx` - Component implementation + - `MyComponent.test.tsx` - Component tests + - `index.ts` - Export + +3. **Export from main index** + ```typescript + // src/index.ts + export { MyComponent } from './components/MyComponent'; + ``` + +### Testing Commands: + +```bash +npm test # Run all tests +npm run test:watch # Watch mode +npm run test:coverage # Coverage report +``` + +### Build Commands: + +```bash +npm run build # Build for production +npm run dev # Development mode +``` + +--- + +## โš ๏ธ Common Gotchas + +### 1. Auto-dismiss Timers + +```typescript +// โœ… Clean up timers +useEffect(() => { + const timer = setTimeout(() => dismiss(), duration); + return () => clearTimeout(timer); +}, [duration]); +``` + +### 2. Notification Queue Management + +```typescript +// โœ… Limit queue size +const MAX_NOTIFICATIONS = 5; +const addNotification = (notif) => { + setNotifications((prev) => [...prev.slice(-MAX_NOTIFICATIONS + 1), notif]); +}; +``` + +### 3. Z-index Management + +```typescript +// Define consistent z-index scale +const NOTIFICATION_Z_INDEX = 9999; +``` + +--- + +## ๐Ÿ“ฆ Dependencies + +**Keep minimal:** + +- โœ… React 18+ +- โœ… TypeScript 5+ +- โŒ Avoid heavy animation libraries +- โŒ Avoid CSS-in-JS unless necessary + +--- + +## ๐Ÿ“‹ Code Review Checklist + +Before submitting PR: + +- [ ] All components have tests (80%+ coverage) +- [ ] JSDoc comments on all exports +- [ ] Props interface documented +- [ ] Accessibility attributes added +- [ ] TypeScript strict mode passes +- [ ] No console.log statements +- [ ] Updated exports in main index.ts + +--- + +## ๐Ÿ”— Integration Guidelines + +**With backend NotificationKit:** + +```typescript +import { useNotifications } from '@ciscode/ui-notification-kit'; +import { notificationSocket } from '@ciscode/notification-kit'; + +// Listen to backend events +notificationSocket.on('notification', (data) => { + notify({ type: data.type, message: data.message }); +}); +``` + +--- diff --git a/.github/instructions/features.instructions.md b/.github/instructions/features.instructions.md new file mode 100644 index 0000000..5bd84ec --- /dev/null +++ b/.github/instructions/features.instructions.md @@ -0,0 +1,404 @@ +# Features Instructions - UI Kit Module + +> **Last Updated**: February 2026 + +--- + +## ๐Ÿš€ Before Starting Any Feature + +### Pre-Implementation Checklist + +- [ ] **Check existing components** - Avoid duplication +- [ ] **Understand scope** - Breaking change? (MAJOR version) +- [ ] **Review component API** - Changes to props? +- [ ] **Check dependencies** - Need new npm packages? +- [ ] **Plan backwards compatibility** - Can users upgrade? +- [ ] **Consider accessibility** - WCAG compliance? + +### Questions to Ask + +1. **Already exists?** + + ```bash + grep -r "ComponentName" src/ + ``` + +2. **Right abstraction level?** + - Too specific? + - Reusable enough? + +3. **Impact assessment?** + - Breaking โ†’ MAJOR version + - New component โ†’ MINOR version + - Enhancement โ†’ PATCH version + +--- + +## ๐Ÿ“‹ Implementation Workflow + +``` +1. Design โ†’ 2. Implement โ†’ 3. Test โ†’ 4. Document โ†’ 5. Release +``` + +### 1๏ธโƒฃ Design Phase + +- [ ] Define component props interface +- [ ] Plan accessibility requirements +- [ ] Design keyboard interactions +- [ ] Consider responsive behavior + +### 2๏ธโƒฃ Implementation Phase + +- [ ] Create feature branch: `feature/ComponentName` +- [ ] Implement component +- [ ] Add TypeScript types +- [ ] Add accessibility attributes +- [ ] Handle edge cases +- [ ] Add prop validation + +### 3๏ธโƒฃ Testing Phase + +- [ ] Unit tests for logic +- [ ] Component interaction tests +- [ ] Accessibility tests +- [ ] Edge case tests +- [ ] Coverage >= 80% + +### 4๏ธโƒฃ Documentation Phase + +- [ ] JSDoc for component +- [ ] Props documentation +- [ ] Usage examples in README +- [ ] Update CHANGELOG +- [ ] Add Storybook story (if applicable) + +### 5๏ธโƒฃ Release Phase + +- [ ] Bump version: `npm version [minor|major]` +- [ ] Build library +- [ ] Create PR to `develop` +- [ ] Release from `master` + +--- + +## โž• Adding New Component + +### Example: Badge Component + +**Step 1: Design Props Interface** + +```typescript +// src/components/Badge/Badge.tsx +export interface BadgeProps { + /** Badge content */ + children: React.ReactNode; + /** Visual variant */ + variant?: 'default' | 'success' | 'warning' | 'error'; + /** Size variant */ + size?: 'sm' | 'md' | 'lg'; + /** Custom CSS class */ + className?: string; + /** Accessibility label */ + 'aria-label'?: string; +} +``` + +**Step 2: Implement Component** + +````typescript +/** + * Badge component for displaying status or counts + * + * @example + * ```tsx + * Active + * 3 + * ``` + */ +export function Badge({ + children, + variant = 'default', + size = 'md', + className, + 'aria-label': ariaLabel, +}: BadgeProps): JSX.Element { + const variantClasses = { + default: 'bg-gray-100 text-gray-800', + success: 'bg-green-100 text-green-800', + warning: 'bg-yellow-100 text-yellow-800', + error: 'bg-red-100 text-red-800', + }; + + const sizeClasses = { + sm: 'text-xs px-2 py-0.5', + md: 'text-sm px-2.5 py-1', + lg: 'text-base px-3 py-1.5', + }; + + return ( + + {children} + + ); +} +```` + +**Step 3: Write Tests** + +```typescript +// src/components/Badge/Badge.test.tsx +import { render, screen } from '@testing-library/react'; +import { Badge } from './Badge'; + +describe('Badge', () => { + it('should render with text', () => { + render(Active); + + expect(screen.getByRole('status')).toHaveTextContent('Active'); + }); + + it('should apply variant styles', () => { + render(Success); + + const badge = screen.getByRole('status'); + expect(badge.className).toMatch(/bg-green-100/); + }); + + it('should apply size classes', () => { + render(Large); + + const badge = screen.getByRole('status'); + expect(badge.className).toMatch(/text-base/); + }); + + it('should accept custom className', () => { + render(Test); + + expect(screen.getByRole('status')).toHaveClass('custom-class'); + }); + + it('should have no accessibility violations', async () => { + const { container } = render(Test); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); +``` + +**Step 4: Export Component** + +```typescript +// src/components/Badge/index.ts +export { Badge } from './Badge'; +export type { BadgeProps } from './Badge'; + +// src/index.ts +export { Badge } from './components/Badge'; +export type { BadgeProps } from './components/Badge'; +``` + +--- + +## ๐Ÿช Adding New Hook + +### Example: useLocalStorage Hook + +**Step 1: Implement Hook** + +````typescript +// src/hooks/use-local-storage.ts +import { useState, useEffect } from 'react'; + +/** + * Hook for syncing state with localStorage + * + * @param key - localStorage key + * @param initialValue - Default value + * @returns Tuple of [value, setValue] + * + * @example + * ```tsx + * const [name, setName] = useLocalStorage('name', 'Guest'); + * ``` + */ +export function useLocalStorage(key: string, initialValue: T): [T, (value: T) => void] { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }); + + const setValue = (value: T) => { + try { + setStoredValue(value); + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }; + + return [storedValue, setValue]; +} +```` + +**Step 2: Write Tests** + +```typescript +// src/hooks/use-local-storage.test.ts +import { renderHook, act } from '@testing-library/react'; +import { useLocalStorage } from './use-local-storage'; + +describe('useLocalStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should initialize with default value', () => { + const { result } = renderHook(() => useLocalStorage('test', 'default')); + + expect(result.current[0]).toBe('default'); + }); + + it('should update localStorage when value changes', () => { + const { result } = renderHook(() => useLocalStorage('test', 'initial')); + + act(() => { + result.current[1]('updated'); + }); + + expect(result.current[0]).toBe('updated'); + expect(localStorage.getItem('test')).toBe('"updated"'); + }); + + it('should read existing value from localStorage', () => { + localStorage.setItem('test', '"existing"'); + + const { result } = renderHook(() => useLocalStorage('test', 'default')); + + expect(result.current[0]).toBe('existing'); + }); +}); +``` + +--- + +## ๐ŸŽจ Styling Guidelines + +### Headless Components + +```typescript +// โœ… Accept className prop +interface MyComponentProps { + className?: string; +} + +// โœ… Allow style customization +
+``` + +### CSS Variables + +```typescript +// โœ… Support theming via CSS vars + +``` +```` + +**After (v2.0):** + +```tsx + +``` + +Rename `type` prop to `variant` for consistency. + +``` + +--- + +## ๐Ÿ“‹ Feature Completion Checklist + +- [ ] Component/hook implemented +- [ ] TypeScript types defined +- [ ] Tests written (80%+ coverage) +- [ ] Accessibility verified +- [ ] JSDoc added +- [ ] README with examples +- [ ] CHANGELOG updated +- [ ] Exports updated +- [ ] Breaking changes documented +- [ ] Build succeeds +- [ ] PR created +``` diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md new file mode 100644 index 0000000..162aaa3 --- /dev/null +++ b/.github/instructions/general.instructions.md @@ -0,0 +1,329 @@ +# General Instructions - UI Kit Module + +> **Last Updated**: February 2026 + +--- + +## ๐Ÿ“ฆ Package Overview + +### What is this module? + +This is a production-ready React component library providing reusable UI components for modern applications. + +**Type**: React Component Library +**Framework**: React 18+, TypeScript 5+ +**Build**: Vite/tsup +**Distribution**: NPM package +**License**: MIT + +### Key Characteristics + +| Characteristic | Description | +| ----------------- | ------------------------------------------ | +| **Architecture** | Component-based, hooks-first, composable | +| **Styling** | Headless/unstyled by default, customizable | +| **TypeScript** | Fully typed, strict mode enabled | +| **Accessibility** | WCAG 2.1 AA compliant | +| **Testing** | Target: 80%+ coverage | + +--- + +## ๐Ÿ—๏ธ Component Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ COMPONENT LAYER โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ React Components โ”‚ โ”‚ +โ”‚ โ”‚ - UI Logic โ”‚ โ”‚ +โ”‚ โ”‚ - Event Handling โ”‚ โ”‚ +โ”‚ โ”‚ - Accessibility โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ HOOKS LAYER โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Custom React Hooks โ”‚ โ”‚ +โ”‚ โ”‚ - State Management โ”‚ โ”‚ +โ”‚ โ”‚ - Side Effects โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ CONTEXT LAYER โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Context Providers โ”‚ โ”‚ +โ”‚ โ”‚ - Global State โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ TYPES LAYER โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ TypeScript Interfaces โ”‚ โ”‚ +โ”‚ โ”‚ - Props Types โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## ๐Ÿ“ File Structure + +``` +src/ +โ”œโ”€โ”€ components/ # React components +โ”‚ โ”œโ”€โ”€ Component/ +โ”‚ โ”‚ โ”œโ”€โ”€ Component.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ Component.test.tsx +โ”‚ โ”‚ โ””โ”€โ”€ index.ts +โ”œโ”€โ”€ hooks/ # Custom hooks +โ”‚ โ”œโ”€โ”€ use-hook.ts +โ”‚ โ””โ”€โ”€ use-hook.test.ts +โ”œโ”€โ”€ context/ # Context providers +โ”‚ โ””โ”€โ”€ Provider.tsx +โ”œโ”€โ”€ types/ # TypeScript types +โ”‚ โ””โ”€โ”€ types.ts +โ”œโ”€โ”€ utils/ # Helper functions +โ””โ”€โ”€ index.ts # Public API exports +``` + +--- + +## ๐Ÿ“ Coding Standards + +### Component Patterns + +```typescript +// โœ… Functional components with TypeScript +interface MyComponentProps { + /** Component title */ + title: string; + /** Optional callback */ + onAction?: () => void; +} + +export function MyComponent({ title, onAction }: MyComponentProps) { + return
{title}
; +} + +// โŒ Class components +class MyComponent extends React.Component { } +``` + +### Prop Naming + +```typescript +// โœ… Descriptive, semantic names +interface ButtonProps { + onClick: () => void; + isDisabled?: boolean; + variant?: 'primary' | 'secondary'; +} + +// โŒ Generic, unclear names +interface ButtonProps { + handler: any; + disabled: boolean; + type: string; +} +``` + +### TypeScript Strictness + +```typescript +// โœ… Explicit types +const [count, setCount] = useState(0); + +// โŒ Implicit any +const [count, setCount] = useState(); +``` + +--- + +## ๐ŸŽจ Styling Philosophy + +### Headless by Default + +Components should accept `className` for styling: + +```typescript +interface ComponentProps { + className?: string; +} + +export function Component({ className }: ComponentProps) { + return
Content
; +} +``` + +### CSS Variables for Theming + +```typescript +// Support CSS custom properties +
+``` + +--- + +## โ™ฟ Accessibility Requirements + +### ARIA Attributes + +```typescript +// โœ… Include ARIA for screen readers + + +// โŒ Missing accessibility +
ร—
+``` + +### 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/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: {