+```
+
+---
+
+## βΏ Accessibility Requirements
+
+### ARIA Attributes
+
+```typescript
+// β
Include ARIA for screen readers
+
+```
+
+### Keyboard Navigation
+
+```typescript
+// β
Handle keyboard events
+
e.key === 'Enter' && handleClick()}
+>
+```
+
+---
+
+## π§ͺ Testing Philosophy
+
+- **Target**: 80%+ code coverage
+- **Test user interactions**, not implementation
+- **Mock external dependencies**
+- **Test accessibility** with jest-axe
+
+---
+
+## π Documentation Requirements
+
+### Component JSDoc
+
+````typescript
+/**
+ * Button component with multiple variants
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function Button(props: ButtonProps): JSX.Element;
+````
+
+### Props Interface Documentation
+
+```typescript
+export interface ButtonProps {
+ /** Button text content */
+ children: React.ReactNode;
+ /** Click event handler */
+ onClick?: () => void;
+ /** Visual variant */
+ variant?: 'primary' | 'secondary' | 'danger';
+ /** Disable button interaction */
+ disabled?: boolean;
+}
+```
+
+---
+
+## π Development Workflow
+
+1. **Design** - Plan component API and props
+2. **Implement** - Write component following standards
+3. **Test** - Unit tests with React Testing Library
+4. **Document** - JSDoc and examples
+5. **Release** - Semantic versioning
+
+---
+
+## β οΈ Common Gotchas
+
+### 1. Event Handlers
+
+```typescript
+// β
Use optional callbacks
+onClick?.();
+
+// β Call without checking
+onClick();
+```
+
+### 2. useEffect Cleanup
+
+```typescript
+// β
Clean up side effects
+useEffect(() => {
+ const timer = setTimeout(() => {}, 1000);
+ return () => clearTimeout(timer);
+}, []);
+
+// β Missing cleanup
+useEffect(() => {
+ setTimeout(() => {}, 1000);
+}, []);
+```
+
+### 3. Key Props in Lists
+
+```typescript
+// β
Unique, stable keys
+{items.map(item =>
{item.name}
)}
+
+// β Index as key
+{items.map((item, i) =>
{item.name}
)}
+```
+
+---
+
+## π¦ Build Configuration
+
+Ensure proper build setup:
+
+```json
+{
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "files": ["dist"]
+}
+```
+
+---
+
+## π Testing Commands
+
+```bash
+npm test # Run tests
+npm run test:watch # Watch mode
+npm run test:coverage # Coverage report
+```
+
+---
+
+## π Pre-Release Checklist
+
+- [ ] All tests passing
+- [ ] Coverage >= 80%
+- [ ] JSDoc complete
+- [ ] README with examples
+- [ ] CHANGELOG updated
+- [ ] No console.log statements
+- [ ] Accessibility tested
+- [ ] TypeScript strict mode
+- [ ] Build outputs verified
diff --git a/.github/instructions/sonarqube_mcp.instructions.md b/.github/instructions/sonarqube_mcp.instructions.md
new file mode 100644
index 0000000..1e17f37
--- /dev/null
+++ b/.github/instructions/sonarqube_mcp.instructions.md
@@ -0,0 +1,50 @@
+---
+applyTo: '**/*'
+---
+
+These are some guidelines when using the SonarQube MCP server.
+
+# Important Tool Guidelines
+
+## Basic usage
+
+- **IMPORTANT**: After you finish generating or modifying any code files at the very end of the task, you MUST call the `analyze_file_list` tool (if it exists) to analyze the files you created or modified.
+- **IMPORTANT**: When starting a new task, you MUST disable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
+- **IMPORTANT**: When you are done generating code at the very end of the task, you MUST re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
+
+## Project Keys
+
+- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key
+- Don't guess project keys - always look them up
+
+## Code Language Detection
+
+- When analyzing code snippets, try to detect the programming language from the code syntax
+- If unclear, ask the user or make an educated guess based on syntax
+
+## Branch and Pull Request Context
+
+- Many operations support branch-specific analysis
+- If user mentions working on a feature branch, include the branch parameter
+
+## Code Issues and Violations
+
+- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates
+
+# Common Troubleshooting
+
+## Authentication Issues
+
+- SonarQube requires USER tokens (not project tokens)
+- When the error `SonarQube answered with Not authorized` occurs, verify the token type
+
+## Project Not Found
+
+- Use `search_my_sonarqube_projects` to find available projects
+- Verify project key spelling and format
+
+## Code Analysis Issues
+
+- Ensure programming language is correctly specified
+- Remind users that snippet analysis doesn't replace full project scans
+- Provide full file content for better analysis results
diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md
new file mode 100644
index 0000000..237410e
--- /dev/null
+++ b/.github/instructions/testing.instructions.md
@@ -0,0 +1,408 @@
+# Testing Instructions - UI Kit Module
+
+> **Last Updated**: February 2026
+> **Testing Framework**: Vitest + React Testing Library
+> **Coverage Target**: 80%+
+
+---
+
+## π― Testing Philosophy
+
+### Test User Behavior, Not Implementation
+
+**β
Test what users see and do:**
+
+```typescript
+it('should show error message when form is invalid', async () => {
+ render(
);
+
+ const submitButton = screen.getByRole('button', { name: /submit/i });
+ await userEvent.click(submitButton);
+
+ expect(screen.getByText(/email is required/i)).toBeInTheDocument();
+});
+```
+
+**β Don't test implementation details:**
+
+```typescript
+it('should update state when input changes', () => {
+ const { rerender } = render(
);
+ // Testing internal state = implementation detail
+ expect(component.state.value).toBe('test');
+});
+```
+
+---
+
+## π Coverage Targets
+
+| Layer | Minimum Coverage | Priority |
+| -------------- | ---------------- | ----------- |
+| **Hooks** | 90%+ | π΄ Critical |
+| **Components** | 80%+ | π‘ High |
+| **Utils** | 85%+ | π‘ High |
+| **Context** | 90%+ | π΄ Critical |
+
+**Overall Target**: 80%+
+
+---
+
+## π Test File Organization
+
+### File Placement
+
+Tests live next to components:
+
+```
+src/components/Button/
+ βββ Button.tsx
+ βββ Button.test.tsx β Same directory
+```
+
+### Naming Convention
+
+| Code File | Test File |
+| ------------- | ------------------ |
+| `Button.tsx` | `Button.test.tsx` |
+| `use-auth.ts` | `use-auth.test.ts` |
+
+---
+
+## π Test Structure
+
+### Component Test Template
+
+```typescript
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Button } from './Button';
+
+describe('Button', () => {
+ it('should render with text', () => {
+ render(
);
+
+ expect(screen.getByRole('button', { name: /click me/i }))
+ .toBeInTheDocument();
+ });
+
+ it('should call onClick when clicked', async () => {
+ const handleClick = vi.fn();
+ render(
);
+
+ await userEvent.click(screen.getByRole('button'));
+
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('should be disabled when disabled prop is true', () => {
+ render(
);
+
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+});
+```
+
+### Hook Test Template
+
+```typescript
+import { renderHook, act } from '@testing-library/react';
+import { useCounter } from './use-counter';
+
+describe('useCounter', () => {
+ it('should initialize with default value', () => {
+ const { result } = renderHook(() => useCounter());
+
+ expect(result.current.count).toBe(0);
+ });
+
+ it('should increment count', () => {
+ const { result } = renderHook(() => useCounter());
+
+ act(() => {
+ result.current.increment();
+ });
+
+ expect(result.current.count).toBe(1);
+ });
+
+ it('should decrement count', () => {
+ const { result } = renderHook(() => useCounter(5));
+
+ act(() => {
+ result.current.decrement();
+ });
+
+ expect(result.current.count).toBe(4);
+ });
+});
+```
+
+---
+
+## π Testing Patterns
+
+### Querying Elements
+
+**Prefer accessible queries:**
+
+```typescript
+// β
BEST - By role (accessible)
+screen.getByRole('button', { name: /submit/i });
+screen.getByRole('textbox', { name: /email/i });
+
+// β
GOOD - By label text
+screen.getByLabelText(/email/i);
+
+// β οΈ OK - By test ID (last resort)
+screen.getByTestId('submit-button');
+
+// β BAD - By class or internal details
+container.querySelector('.button-class');
+```
+
+### User Interactions
+
+**Use userEvent over fireEvent:**
+
+```typescript
+import userEvent from '@testing-library/user-event';
+
+// β
GOOD - userEvent (realistic)
+await userEvent.click(button);
+await userEvent.type(input, 'test@example.com');
+
+// β BAD - fireEvent (synthetic)
+fireEvent.click(button);
+fireEvent.change(input, { target: { value: 'test' } });
+```
+
+### Async Testing
+
+```typescript
+// β
Wait for element to appear
+const message = await screen.findByText(/success/i);
+
+// β
Wait for element to disappear
+await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
+
+// β
Wait for assertion
+await waitFor(() => {
+ expect(screen.getByText(/loaded/i)).toBeInTheDocument();
+});
+```
+
+---
+
+## π§ͺ Test Categories
+
+### 1. Component Tests
+
+**What to test:**
+
+- β
Rendering with different props
+- β
User interactions (click, type, etc.)
+- β
Conditional rendering
+- β
Error states
+
+**Example:**
+
+```typescript
+describe('LoginForm', () => {
+ it('should display error for empty email', async () => {
+ render(
);
+
+ const submitBtn = screen.getByRole('button', { name: /login/i });
+ await userEvent.click(submitBtn);
+
+ expect(screen.getByText(/email is required/i)).toBeInTheDocument();
+ });
+
+ it('should call onSuccess when login succeeds', async () => {
+ const onSuccess = vi.fn();
+ render(
);
+
+ await userEvent.type(
+ screen.getByLabelText(/email/i),
+ 'test@example.com'
+ );
+ await userEvent.type(
+ screen.getByLabelText(/password/i),
+ 'password123'
+ );
+ await userEvent.click(screen.getByRole('button', { name: /login/i }));
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({
+ email: 'test@example.com'
+ }));
+ });
+ });
+});
+```
+
+### 2. Hook Tests
+
+**What to test:**
+
+- β
Initial state
+- β
State updates
+- β
Side effects
+- β
Cleanup
+
+**Example:**
+
+```typescript
+describe('useAuth', () => {
+ it('should login user', async () => {
+ const { result } = renderHook(() => useAuth());
+
+ await act(async () => {
+ await result.current.login('test@example.com', 'password');
+ });
+
+ expect(result.current.user).toEqual({
+ email: 'test@example.com',
+ });
+ expect(result.current.isAuthenticated).toBe(true);
+ });
+
+ it('should cleanup on unmount', () => {
+ const cleanup = vi.fn();
+ vi.spyOn(global, 'removeEventListener').mockImplementation(cleanup);
+
+ const { unmount } = renderHook(() => useAuth());
+ unmount();
+
+ expect(cleanup).toHaveBeenCalled();
+ });
+});
+```
+
+### 3. Accessibility Tests
+
+**Use jest-axe:**
+
+```typescript
+import { axe, toHaveNoViolations } from 'jest-axe';
+
+expect.extend(toHaveNoViolations);
+
+it('should have no accessibility violations', async () => {
+ const { container } = render(
);
+
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+});
+```
+
+---
+
+## π¨ Mocking
+
+### Mocking Context
+
+```typescript
+const mockAuthContext = {
+ user: { id: '1', email: 'test@example.com' },
+ login: vi.fn(),
+ logout: vi.fn(),
+ isAuthenticated: true,
+};
+
+const wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+render(
, { wrapper });
+```
+
+### Mocking API Calls
+
+```typescript
+import { vi } from 'vitest';
+
+global.fetch = vi.fn(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ data: 'mocked' }),
+ }),
+);
+```
+
+---
+
+## π§ͺ Test Commands
+
+```bash
+# Run all tests
+npm test
+
+# Watch mode
+npm run test:watch
+
+# Coverage report
+npm run test:coverage
+
+# UI mode (Vitest)
+npm run test:ui
+```
+
+---
+
+## β οΈ Common Mistakes
+
+### 1. Not Waiting for Async Updates
+
+```typescript
+// β BAD - Missing await
+it('test', () => {
+ userEvent.click(button);
+ expect(screen.getByText(/success/i)).toBeInTheDocument();
+});
+
+// β
GOOD - Properly awaited
+it('test', async () => {
+ await userEvent.click(button);
+ expect(await screen.findByText(/success/i)).toBeInTheDocument();
+});
+```
+
+### 2. Testing Implementation Details
+
+```typescript
+// β BAD - Testing internal state
+expect(component.state.isOpen).toBe(true);
+
+// β
GOOD - Testing visible behavior
+expect(screen.getByRole('dialog')).toBeVisible();
+```
+
+### 3. Not Cleaning Up
+
+```typescript
+// β
Always use cleanup
+import { cleanup } from '@testing-library/react';
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+```
+
+---
+
+## π Pre-Merge Checklist
+
+- [ ] All tests passing
+- [ ] Coverage >= 80%
+- [ ] No skipped tests (it.skip)
+- [ ] No focused tests (it.only)
+- [ ] Accessible queries used
+- [ ] userEvent for interactions
+- [ ] Async operations properly awaited
+- [ ] Accessibility tested
+- [ ] Mocks cleaned up
diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml
deleted file mode 100644
index 09d2ba7..0000000
--- a/.github/workflows/cd-release.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-name: Publish to NPM
-
-on:
- push:
- branches:
- - master
- workflow_dispatch:
-
-jobs:
- publish:
- runs-on: ubuntu-latest
-
- permissions:
- contents: read
- packages: write
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
- registry-url: 'https://registry.npmjs.org'
-
- - name: Install dependencies
- run: npm ci
-
- - name: Run lint (if present)
- run: npm run lint --if-present
- continue-on-error: false
-
- - name: Run tests (if present)
- run: npm test --if-present
- continue-on-error: false
-
- - name: Build package
- run: npm run build
-
- - name: Publish to NPM
- run: npm publish --access public
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/ci-release-check.yml b/.github/workflows/ci-release-check.yml
deleted file mode 100644
index a49ce6b..0000000
--- a/.github/workflows/ci-release-check.yml
+++ /dev/null
@@ -1,66 +0,0 @@
-name: CI - Release Check
-
-on:
- pull_request:
- branches: [master]
-
-permissions:
- contents: read
-
-env:
- SONAR_ENABLED: 'false' # set to "true" in real repos
-
-jobs:
- release-check:
- name: CI - Release Check
- # Only run when PR is from develop -> master
- if: github.head_ref == 'develop'
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout (full history for Sonar)
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
-
- - name: Install
- run: npm ci
-
- - name: Format (check)
- run: npm run format
-
- - name: Lint
- run: npm run lint
-
- - name: Typecheck
- run: npm run typecheck
-
- - name: Test
- run: npm test
-
- - name: Build
- run: npm run build
-
- # --- SonarQube scan + Quality Gate ---
- - name: SonarQube Scan
- if: env.SONAR_ENABLED == 'true' && (github.event.pull_request.head.repo.fork == false)
- uses: sonarsource/sonarqube-scan-action@v4
- env:
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- with:
- args: >
- -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }}
-
- - name: SonarQube Quality Gate
- if: env.SONAR_ENABLED == 'true' && (github.event.pull_request.head.repo.fork == false)
- uses: sonarsource/sonarqube-quality-gate-action@v1.1.0
- env:
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
diff --git a/.github/workflows/ci-pr-validation.yml b/.github/workflows/pr-validation.yml
similarity index 91%
rename from .github/workflows/ci-pr-validation.yml
rename to .github/workflows/pr-validation.yml
index a2b018a..c8ac0d3 100644
--- a/.github/workflows/ci-pr-validation.yml
+++ b/.github/workflows/pr-validation.yml
@@ -3,8 +3,6 @@ name: CI - PR Validation
on:
pull_request:
branches: [develop]
- push:
- branches: [develop]
permissions:
contents: read
@@ -21,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
new file mode 100644
index 0000000..ffe4408
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,82 @@
+name: Publish to NPM
+
+on:
+ push:
+ 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: Validate version tag and package.json
+ run: |
+ 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: '22'
+ registry-url: 'https://registry.npmjs.org'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build
+ run: npm run build --if-present
+
+ - name: Lint
+ run: npm run lint --if-present 2>/dev/null || true
+
+ - name: Test
+ run: npm test --if-present 2>/dev/null || true
+
+ - name: Publish to NPM
+ 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
new file mode 100644
index 0000000..89a0bfe
--- /dev/null
+++ b/.github/workflows/release-check.yml
@@ -0,0 +1,199 @@
+name: CI - Release Check
+
+on:
+ pull_request:
+ branches: [master]
+
+concurrency:
+ group: ci-release-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ SONAR_HOST_URL: 'https://sonarcloud.io'
+ SONAR_ORGANIZATION: 'ciscode'
+ SONAR_PROJECT_KEY: 'CISCODE-MA_FormKit-UI'
+ NODE_VERSION: '22'
+
+# βββ Job 1: Static checks (fast feedback, runs in parallel with test) ββββββββββ
+jobs:
+ quality:
+ name: Quality Checks
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Install
+ run: npm ci
+
+ - name: Security Audit
+ # Only fail on high/critical β moderate noise in dev deps is expected
+ run: npm audit --production --audit-level=high
+
+ - name: Format
+ run: npm run format
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Lint
+ run: npm run lint
+
+ # βββ Job 2: Tests + Coverage (artifact passed to Sonar) ββββββββββββββββββββββββ
+ test:
+ name: Test & Coverage
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Install
+ run: npm ci
+
+ - name: Test (with coverage)
+ run: npm run test:cov
+
+ - name: Upload coverage report
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage/
+ retention-days: 1
+
+ # βββ Job 3: Build ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ needs: [quality, test]
+ timeout-minutes: 10
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'npm'
+
+ - name: Install
+ run: npm ci
+
+ - name: Build
+ run: npm run build
+
+ # βββ Job 4: SonarCloud (depends on test for coverage data) βββββββββββββββββββββ
+ sonar:
+ name: SonarCloud Analysis
+ runs-on: ubuntu-latest
+ needs: [test]
+ timeout-minutes: 15
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ # Full history required for accurate blame & new code detection
+ fetch-depth: 0
+
+ - name: Download coverage report
+ uses: actions/download-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage/
+
+ - name: Cache SonarCloud packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.sonar/cache
+ key: sonar-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: sonar-${{ runner.os }}-
+
+ - name: SonarCloud Scan
+ 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=src
+ -Dsonar.test.inclusions=**/*.spec.ts,**/*.spec.tsx,**/*.test.ts,**/*.test.tsx
+ -Dsonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/**,**/*.d.ts
+ -Dsonar.coverage.exclusions=**/*.spec.ts,**/*.spec.tsx,**/*.test.ts,**/*.test.tsx,**/index.ts
+ -Dsonar.cpd.exclusions=src/components/fields/DateField.tsx,src/components/fields/DateTimeField.tsx,src/components/fields/TimeField.tsx,src/components/fields/SelectField.tsx,src/components/fields/MultiSelectField.tsx
+ -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
+ -Dsonar.typescript.tsconfigPath=tsconfig.json
+ -Dsonar.qualitygate.wait=true
+ -Dsonar.qualitygate.timeout=300
+
+ # βββ Job 5: Final status report (always runs) ββββββββββββββββββββββββββββββββββ
+ report:
+ name: Report CI Status
+ runs-on: ubuntu-latest
+ needs: [quality, test, build, sonar]
+ # Run even if upstream jobs failed
+ if: always()
+ timeout-minutes: 5
+
+ permissions:
+ contents: read
+ statuses: write
+
+ steps:
+ - name: Resolve overall result
+ id: result
+ run: |
+ results="${{ needs.quality.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.sonar.result }}"
+ if echo "$results" | grep -qE "failure|cancelled"; then
+ echo "state=failure" >> $GITHUB_OUTPUT
+ echo "desc=One or more CI checks failed" >> $GITHUB_OUTPUT
+ else
+ echo "state=success" >> $GITHUB_OUTPUT
+ echo "desc=All CI checks passed" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Post commit status
+ uses: actions/github-script@v7
+ with:
+ script: |
+ await github.rest.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: context.sha,
+ state: '${{ steps.result.outputs.state }}',
+ context: 'CI / Release Check',
+ description: '${{ steps.result.outputs.desc }}',
+ target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
+ })
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..a2227d5 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,72 @@
-# React TypeScript DeveloperKit (Template)
+# @ciscode/ui-form-kit
-Template repository for building reusable React TypeScript **npm libraries**
-(components + hooks + utilities).
+FormKit-UI is a reusable React + TypeScript form library that provides ready-to-use field components, form orchestration helpers, and hooks for building complex forms quickly.
-## What you get
+[](https://sonarcloud.io/summary/new_code?id=CISCODE-MA_FormKit-UI)
+[](https://www.npmjs.com/package/@ciscode/ui-form-kit)
+[](https://opensource.org/licenses/MIT)
+[](https://www.typescriptlang.org/)
-- 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
+## Highlights
-## Package structure
+- Rich field set: text, textarea, select, multiselect, date, time, datetime, phone, file, OTP, slider, range slider, tags, rating, checkbox, switch
+- Dynamic form rendering with schema-driven configuration
+- Built-in i18n support (`en`, `fr`) with custom translation overrides
+- Validation helpers and conditional field rendering
+- ESM + CJS + types output for package consumers
-- `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)
+## Install
-Anything not exported from `src/index.ts` is considered private.
+```bash
+npm install @ciscode/ui-form-kit
+```
-## Scripts
+Peer dependencies:
+
+- `react >= 18`
+- `react-dom >= 18`
+
+## Project Layout
+
+- `src/components` UI fields, form container, layout primitives, and contexts
+- `src/hooks` reusable hooks (`useFormKit`, `useFormContext`, `useI18n`, etc.)
+- `src/core` core helpers (validators, conditional logic, i18n utilities)
+- `src/locales` translation dictionaries
+- `src/models` type models and configuration contracts
+- `src/index.ts` public API surface
+
+## Quality Standards
-- `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
+Local quality gates expected before merge:
+
+- `npm run lint`
+- `npm run typecheck`
+- `npm run test:cov`
+- `npm run build`
+
+Coverage thresholds are enforced in Vitest:
+
+- statements: 80%
+- branches: 80%
+- functions: 80%
+- lines: 80%
+
+## Scripts
-## Release flow (summary)
+- `npm run clean` remove build and coverage artifacts
+- `npm run build` build package output and bundled styles
+- `npm run lint` run ESLint
+- `npm run typecheck` run TypeScript checks
+- `npm test` run Vitest
+- `npm run test:cov` run tests with coverage thresholds
+- `npm run verify` run lint + typecheck + coverage
+- `npm run format` / `npm run format:write` run Prettier
+- `npx changeset` create a changeset
-- 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)
+## Release Flow
-This repository is a **template**. Teams should clone it and focus only on
-library logic, not tooling or release mechanics.
+- Work from feature branches off `develop`
+- Merge into `develop`
+- Add changesets for user-facing changes
+- Promote `develop` to `master`
+- Publish tagged versions
diff --git a/eslint.config.js b/eslint.config.js
index 7fb4490..cf11d28 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -6,11 +6,19 @@ import prettier from 'eslint-config-prettier';
export default [
{
- ignores: ['dist/**', 'node_modules/**', 'coverage/**', '.vitest/**'],
+ ignores: [
+ 'dist/**',
+ '*.d.ts',
+ 'node_modules/**',
+ 'coverage/**',
+ '.vitest/**',
+ 'postcss.config.js',
+ 'tailwind.config.js',
+ 'build/**',
+ ],
},
js.configs.recommended,
-
...tseslint.configs.recommended,
{
@@ -29,32 +37,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 8d50d62..e35d756 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,26 +1,35 @@
{
- "name": "@ciscode/reactts-developerkit",
- "version": "0.0.0",
+ "name": "@ciscode/ui-form-kit",
+ "version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "@ciscode/reactts-developerkit",
- "version": "0.0.0",
+ "name": "@ciscode/ui-form-kit",
+ "version": "0.0.1",
"license": "MIT",
"devDependencies": {
"@changesets/cli": "^2.27.8",
"@eslint/js": "^9.39.2",
+ "@tailwindcss/cli": "^4.2.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "@vitest/coverage-v8": "^2.1.9",
+ "autoprefixer": "^10.4.24",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"husky": "^9.1.7",
+ "jsdom": "^28.0.0",
"lint-staged": "^15.2.10",
+ "postcss": "^8.5.6",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
+ "tailwindcss": "^4.2.1",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"typescript-eslint": "^8.50.1",
@@ -34,6 +43,69 @@
"react-dom": ">=18"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
+ "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.0.0",
+ "@csstools/css-color-parser": "^4.0.1",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.5"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.7.8",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz",
+ "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.5"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -314,6 +386,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/@changesets/apply-release-plan": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.14.tgz",
@@ -589,6 +668,138 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
+ "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
+ "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.1",
+ "@csstools/css-calc": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.0.27",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
+ "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0"
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -1201,6 +1412,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
+ "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1298,6 +1527,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",
@@ -1458,24 +1787,67 @@
"node": ">= 8"
}
},
- "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",
- "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
+ "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.3",
+ "is-glob": "^4.0.3",
+ "node-addon-api": "^7.0.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.6",
+ "@parcel/watcher-darwin-arm64": "2.5.6",
+ "@parcel/watcher-darwin-x64": "2.5.6",
+ "@parcel/watcher-freebsd-x64": "2.5.6",
+ "@parcel/watcher-linux-arm-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm-musl": "2.5.6",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm64-musl": "2.5.6",
+ "@parcel/watcher-linux-x64-glibc": "2.5.6",
+ "@parcel/watcher-linux-x64-musl": "2.5.6",
+ "@parcel/watcher-win32-arm64": "2.5.6",
+ "@parcel/watcher-win32-ia32": "2.5.6",
+ "@parcel/watcher-win32-x64": "2.5.6"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
+ "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
- "arm"
+ "arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
- ]
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
},
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
- "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
+ "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
@@ -1483,27 +1855,41 @@
"license": "MIT",
"optional": true,
"os": [
- "android"
- ]
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
},
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
- "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
+ "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
- "arm64"
+ "x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
},
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
- "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
+ "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
@@ -1511,9 +1897,285 @@
"license": "MIT",
"optional": true,
"os": [
- "darwin"
- ]
- },
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
+ "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
+ "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
+ "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
+ "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
+ "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
+ "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
+ "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
+ "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
+ "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "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",
+ "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.54.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
+ "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.54.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
+ "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.54.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
+ "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
@@ -1715,47 +2377,320 @@
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"cpu": [
- "arm64"
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.54.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
+ "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.54.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
+ "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.54.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
+ "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tailwindcss/cli": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz",
+ "integrity": "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@parcel/watcher": "^2.5.1",
+ "@tailwindcss/node": "4.2.1",
+ "@tailwindcss/oxide": "4.2.1",
+ "enhanced-resolve": "^5.19.0",
+ "mri": "^1.2.0",
+ "picocolors": "^1.1.1",
+ "tailwindcss": "4.2.1"
+ },
+ "bin": {
+ "tailwindcss": "dist/index.mjs"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
+ "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.31.1",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
+ "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.1",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.1",
+ "@tailwindcss/oxide-darwin-x64": "4.2.1",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.1",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.1",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.1",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
+ "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
+ "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
+ "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
+ "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
+ "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
+ "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
+ "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
+ "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
+ "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
+ "cpu": [
+ "x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
- "win32"
- ]
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
},
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
- "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
+ "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
"cpu": [
- "ia32"
+ "wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
- "os": [
- "win32"
- ]
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
},
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
- "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
+ "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
"cpu": [
- "x64"
+ "arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
},
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.54.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
- "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
+ "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
"cpu": [
"x64"
],
@@ -1764,7 +2699,108 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -1780,18 +2816,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/node": {
- "version": "25.0.3",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
- "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "undici-types": "~7.16.0"
- }
- },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2076,6 +3100,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",
@@ -2226,6 +3283,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2306,6 +3373,16 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -2474,6 +3551,43 @@
"node": ">= 0.4"
}
},
+ "node_modules/autoprefixer": {
+ "version": "10.4.24",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
+ "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
+ "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.30001766",
+ "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",
@@ -2520,6 +3634,16 @@
"node": ">=4"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2665,9 +3789,9 @@
}
},
"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.30001774",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
+ "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
"dev": true,
"funding": [
{
@@ -2880,6 +4004,43 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.12.2",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
+ "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.21",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.4"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2887,6 +4048,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -2959,6 +4134,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -3012,6 +4194,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-indent": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
@@ -3022,6 +4214,16 @@
"node": ">=8"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -3048,6 +4250,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3063,6 +4273,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",
@@ -3077,6 +4294,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/enhanced-resolve": {
+ "version": "5.19.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
+ "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/enquirer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
@@ -3091,6 +4322,19 @@
"node": ">=8.6"
}
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
@@ -3922,14 +5166,45 @@
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "is-callable": "^1.2.7"
- },
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "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": ">= 0.4"
+ "node": "*"
},
"funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fs-extra": {
@@ -4319,6 +5594,54 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "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",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/human-id": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz",
@@ -4419,6 +5742,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4694,6 +6027,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4889,6 +6229,60 @@
"dev": true,
"license": "ISC"
},
+ "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": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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": "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",
@@ -4907,6 +6301,32 @@
"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": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@@ -4933,105 +6353,406 @@
"dependencies": {
"argparse": "^2.0.1"
},
- "bin": {
- "js-yaml": "bin/js-yaml.js"
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "28.0.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz",
+ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.31",
+ "@asamuzakjp/dom-selector": "^6.7.6",
+ "@exodus/bytes": "^1.11.0",
+ "cssstyle": "^5.3.7",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "undici": "^7.20.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonfile": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "dev": true,
+ "license": "MIT",
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
+ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
+ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.31.1",
+ "lightningcss-darwin-arm64": "1.31.1",
+ "lightningcss-darwin-x64": "1.31.1",
+ "lightningcss-freebsd-x64": "1.31.1",
+ "lightningcss-linux-arm-gnueabihf": "1.31.1",
+ "lightningcss-linux-arm64-gnu": "1.31.1",
+ "lightningcss-linux-arm64-musl": "1.31.1",
+ "lightningcss-linux-x64-gnu": "1.31.1",
+ "lightningcss-linux-x64-musl": "1.31.1",
+ "lightningcss-win32-arm64-msvc": "1.31.1",
+ "lightningcss-win32-x64-msvc": "1.31.1"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
+ "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
+ "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
+ "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/jsesc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
- "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
+ "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "MIT",
- "bin": {
- "jsesc": "bin/jsesc"
- },
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
"engines": {
- "node": ">=6"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/json-buffer": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
- "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/json-schema-traverse": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
+ "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
+ "cpu": [
+ "arm"
+ ],
"dev": true,
- "license": "MIT"
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
},
- "node_modules/json-stable-stringify-without-jsonify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
- "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
+ "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "MIT"
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
},
- "node_modules/json5": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
- "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
+ "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
- },
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=6"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/jsonfile": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
- "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
+ "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "MIT",
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/jsx-ast-utils": {
- "version": "3.3.5",
- "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
- "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
+ "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "MIT",
- "dependencies": {
- "array-includes": "^3.1.6",
- "array.prototype.flat": "^1.3.1",
- "object.assign": "^4.1.4",
- "object.values": "^1.1.6"
- },
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
"engines": {
- "node": ">=4.0"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
+ "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "MIT",
- "dependencies": {
- "json-buffer": "3.0.1"
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
- "node_modules/levn": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
- "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
+ "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "license": "MIT",
- "dependencies": {
- "prelude-ls": "^1.2.1",
- "type-check": "~0.4.0"
- },
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
"engines": {
- "node": ">= 0.8.0"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
"node_modules/lilconfig": {
@@ -5240,15 +6961,26 @@
"license": "MIT"
},
"node_modules/lru-cache": {
- "version": "11.2.4",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
- "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5259,6 +6991,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",
@@ -5269,6 +7029,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+ "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -5326,6 +7093,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
@@ -5420,6 +7197,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -5715,6 +7499,19 @@
"node": ">=6"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5933,6 +7730,13 @@
}
}
},
+ "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",
@@ -5959,6 +7763,44 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6103,6 +7945,20 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6147,6 +8003,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -6374,6 +8240,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6683,6 +8562,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",
@@ -6823,6 +8735,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",
@@ -6846,6 +8772,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -6918,6 +8857,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
+ "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/term-size": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz",
@@ -6931,6 +8898,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.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
+ "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
+ "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.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "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",
@@ -7046,6 +9146,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "7.0.23",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
+ "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.23"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.23",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
+ "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -7059,6 +9179,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+ "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -7297,14 +9443,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/undici-types": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
- "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "node_modules/undici": {
+ "version": "7.21.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz",
+ "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==",
"dev": true,
"license": "MIT",
- "optional": true,
- "peer": true
+ "engines": {
+ "node": ">=20.18.1"
+ }
},
"node_modules/universalify": {
"version": "0.1.2",
@@ -7950,6 +10097,54 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz",
+ "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8100,6 +10295,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",
@@ -8129,6 +10391,23 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 628f673..f5b30cb 100644
--- a/package.json
+++ b/package.json
@@ -1,24 +1,28 @@
{
"name": "@ciscode/ui-form-kit",
- "version": "0.0.0-dev.0",
+ "version": "0.0.1",
"author": "CISCODE",
"description": "A set of form primitives and utilities: input components, validation helpers, error handling, and field generators",
"license": "MIT",
"private": false,
"type": "module",
- "sideEffects": false,
+ "sideEffects": [
+ "**/*.css"
+ ],
"files": [
- "dist"
+ "dist",
+ "dist/styles.css"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
- "import": "./dist/index.mjs",
+ "import": "./dist/index.js",
"require": "./dist/index.cjs"
- }
+ },
+ "./styles.css": "./dist/styles.css"
},
"main": "./dist/index.cjs",
- "module": "./dist/index.mjs",
+ "module": "./dist/index.js",
"types": "./dist/index.d.ts",
"publishConfig": {
"access": "public"
@@ -30,36 +34,48 @@
"devDependencies": {
"@changesets/cli": "^2.27.8",
"@eslint/js": "^9.39.2",
+ "@tailwindcss/cli": "^4.2.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "@vitest/coverage-v8": "^2.1.9",
+ "autoprefixer": "^10.4.24",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"husky": "^9.1.7",
+ "jsdom": "^28.0.0",
"lint-staged": "^15.2.10",
+ "postcss": "^8.5.6",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
+ "tailwindcss": "^4.2.1",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"typescript-eslint": "^8.50.1",
"vitest": "^2.1.8"
},
"scripts": {
- "clean": "rimraf dist *.tsbuildinfo",
- "build": "tsup",
+ "clean": "rimraf dist *.tsbuildinfo && rm -rf coverage",
+ "build": "tsup && npx @tailwindcss/cli -i src/styles.css -o dist/styles.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.cjs b/postcss.config.cjs
new file mode 100644
index 0000000..63ef4c6
--- /dev/null
+++ b/postcss.config.cjs
@@ -0,0 +1,7 @@
+/* eslint-disable no-undef */
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
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/context/FormKitContext.tsx b/src/components/context/FormKitContext.tsx
new file mode 100644
index 0000000..e8be687
--- /dev/null
+++ b/src/components/context/FormKitContext.tsx
@@ -0,0 +1,40 @@
+/**
+ * FormKitContext - React context for form state propagation
+ * Internal only β not exported from public API
+ *
+ * Context and hook are in hooks/FormKitContext.ts to maintain layer separation.
+ * This file provides the Provider component that uses that context.
+ */
+
+import type { ReactNode, JSX } from 'react';
+import { FormKitContext, useFormKitContext } from '../../hooks/FormKitContext';
+import type { FormContextValue } from '../../models/FormState';
+import type { FormValues } from '../../core/types';
+
+// Re-export for backward compatibility with components that import from here
+export { FormKitContext, useFormKitContext };
+
+/**
+ * Props for FormKitProvider
+ */
+type FormKitProviderProps = {
+ /** Form context value from useFormKit */
+ value: FormContextValue;
+ /** Child components */
+ children: ReactNode;
+};
+
+/**
+ * Provider component for form context
+ * Wraps DynamicForm children to provide access to form state
+ *
+ * @internal
+ */
+export default function FormKitProvider({
+ value,
+ children,
+}: FormKitProviderProps): JSX.Element {
+ return (
+ {children}
+ );
+}
diff --git a/src/components/context/I18nContext.tsx b/src/components/context/I18nContext.tsx
new file mode 100644
index 0000000..864e558
--- /dev/null
+++ b/src/components/context/I18nContext.tsx
@@ -0,0 +1,128 @@
+/**
+ * I18nContext - Internationalization context provider
+ * Provides translations throughout the form components
+ */
+
+import { createContext, type ReactNode, type JSX, useMemo } from 'react';
+import type { Locale, TranslationKeys } from '../../core/i18n';
+import { DEFAULT_LOCALE, getTranslation } from '../../core/i18n';
+import { en, fr } from '../../locales';
+
+/**
+ * All available translations
+ */
+const translations: Record = {
+ en,
+ fr,
+};
+
+/**
+ * I18n context value
+ */
+export interface I18nContextValue {
+ /** Current locale */
+ locale: Locale;
+ /** Get translation by key path with optional parameter interpolation */
+ t: (path: string, params?: Record, fallback?: string) => string;
+ /** Full translations object for current locale */
+ translations: TranslationKeys;
+}
+
+/**
+ * Default context value with English locale
+ */
+const defaultContextValue: I18nContextValue = {
+ locale: DEFAULT_LOCALE,
+ t: (path: string, params?: Record, fallback?: string) =>
+ getTranslation(translations[DEFAULT_LOCALE], path, params, fallback),
+ translations: translations[DEFAULT_LOCALE],
+};
+
+/**
+ * I18n React context
+ */
+export const I18nContext = createContext(defaultContextValue);
+
+/**
+ * Props for I18nProvider
+ */
+type I18nProviderProps = {
+ /** Locale to use */
+ locale?: Locale;
+ /** Custom translations (overrides built-in) */
+ customTranslations?: Partial;
+ /** Children */
+ children: ReactNode;
+};
+
+/**
+ * I18nProvider component
+ * Wraps form components to provide translations
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
+export default function I18nProvider({
+ locale = DEFAULT_LOCALE,
+ customTranslations,
+ children,
+}: I18nProviderProps): JSX.Element {
+ const contextValue = useMemo(() => {
+ // Merge custom translations with built-in
+ const baseTranslations = translations[locale];
+ const mergedTranslations: TranslationKeys = customTranslations
+ ? (deepMerge(
+ baseTranslations as unknown as Record,
+ customTranslations as unknown as Record,
+ ) as unknown as TranslationKeys)
+ : baseTranslations;
+
+ return {
+ locale,
+ t: (path: string, params?: Record, fallback?: string) =>
+ getTranslation(mergedTranslations, path, params, fallback),
+ translations: mergedTranslations,
+ };
+ }, [locale, customTranslations]);
+
+ return {children};
+}
+
+/**
+ * Deep merge utility for translations
+ */
+function deepMerge>(target: T, source: Partial): T {
+ const result = { ...target };
+
+ for (const key in source) {
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
+ const sourceValue = source[key];
+ const targetValue = result[key];
+
+ if (
+ sourceValue !== undefined &&
+ typeof sourceValue === 'object' &&
+ sourceValue !== null &&
+ !Array.isArray(sourceValue) &&
+ typeof targetValue === 'object' &&
+ targetValue !== null &&
+ !Array.isArray(targetValue)
+ ) {
+ result[key] = deepMerge(
+ targetValue as Record,
+ sourceValue as Record,
+ ) as T[Extract];
+ } else if (sourceValue !== undefined) {
+ result[key] = sourceValue as T[Extract];
+ }
+ }
+ }
+
+ return result;
+}
+
+export type { I18nProviderProps };
diff --git a/src/components/context/__tests__/I18nContext.test.tsx b/src/components/context/__tests__/I18nContext.test.tsx
new file mode 100644
index 0000000..8115d05
--- /dev/null
+++ b/src/components/context/__tests__/I18nContext.test.tsx
@@ -0,0 +1,63 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import I18nProvider from '../I18nContext';
+import { useI18n } from '../../../hooks/useI18n';
+import { getTranslation } from '../../../core/i18n';
+import { en } from '../../../locales';
+
+function Probe() {
+ const { locale, t, translations } = useI18n();
+
+ return (
+
+ {locale}
+ {t('form.submit')}
+ {t('form.unknown', undefined, 'fallback')}
+ {translations.datetime.months.january}
+
+ );
+}
+
+describe('I18nContext', () => {
+ it('uses default english locale', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('locale')).toHaveTextContent('en');
+ expect(screen.getByTestId('submit')).toHaveTextContent('Submit');
+ expect(screen.getByTestId('fallback')).toHaveTextContent('fallback');
+ });
+
+ it('supports french locale and custom translation overrides', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('locale')).toHaveTextContent('fr');
+ expect(screen.getByTestId('submit')).toHaveTextContent('Envoyer');
+ expect(screen.getByTestId('raw-month').textContent).toBeTruthy();
+ });
+
+ it('getTranslation interpolates params and returns path if missing', () => {
+ const merged = {
+ ...en,
+ array: {
+ ...en.array,
+ minHint: 'Minimum {min}',
+ },
+ };
+
+ expect(getTranslation(merged, 'array.minHint', { min: 2 })).toBe('Minimum 2');
+ expect(getTranslation(merged, 'array.notExists')).toBe('array.notExists');
+ });
+});
diff --git a/src/components/context/index.ts b/src/components/context/index.ts
new file mode 100644
index 0000000..db3e773
--- /dev/null
+++ b/src/components/context/index.ts
@@ -0,0 +1,5 @@
+/**
+ * Context module exports
+ */
+
+export { default as FormKitProvider, FormKitContext, useFormKitContext } from './FormKitContext';
diff --git a/src/components/fields/ArrayField.tsx b/src/components/fields/ArrayField.tsx
new file mode 100644
index 0000000..0b59c73
--- /dev/null
+++ b/src/components/fields/ArrayField.tsx
@@ -0,0 +1,512 @@
+/**
+ * ArrayField - Repeatable field group with reordering, collapsible rows, and rich accessibility
+ */
+
+import type { JSX, ReactNode } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { FieldType } from '../../core/types';
+import type { FieldConfig } from '../../models/FieldConfig';
+import type { FieldValue, FormValues } from '../../core/types';
+import { FormKitContext, useFormKitContext } from '../context/FormKitContext';
+import type { FormContextValue } from '../../models/FormState';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+import Field from './Field';
+
+/**
+ * Props for ArrayField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * Props for ArrayRowProvider
+ */
+type ArrayRowProviderProps = {
+ /** Row data */
+ rowData: Record;
+ /** Row index */
+ rowIndex: number;
+ /** Parent array key */
+ arrayKey: string;
+ /** Callback to update a field in the row */
+ onFieldChange: (rowIndex: number, fieldKey: string, value: FieldValue) => void;
+ /** Whether the array is disabled */
+ isDisabled: boolean;
+ /** Children to render */
+ children: ReactNode;
+};
+
+function getRowHintMessage(
+ minRows: number,
+ maxRows: number,
+ t: (key: string, params?: Record) => string,
+): string {
+ if (minRows > 0 && maxRows < Infinity) {
+ return t('array.minMaxHint', { min: minRows, max: maxRows });
+ }
+ if (minRows > 0) {
+ return t('array.minHint', { min: minRows });
+ }
+ return t('array.maxHint', { max: maxRows });
+}
+
+/**
+ * Provides a scoped FormKitContext for each array row
+ * This allows nested Field components to use the existing field implementations
+ * Routes validation errors from parent context using path notation (e.g., contacts.0.email)
+ */
+function ArrayRowProvider({
+ rowData,
+ rowIndex,
+ arrayKey,
+ onFieldChange,
+ isDisabled,
+ children,
+}: ArrayRowProviderProps): JSX.Element {
+ const parentContext = useFormKitContext();
+
+ // Track touched state per field within this row (local state for immediate feedback)
+ const [localTouched, setLocalTouched] = useState>(new Set());
+
+ // Create a scoped context value for this row
+ // Routes errors from parent context using path notation: arrayKey.rowIndex.fieldKey
+ const scopedContext = useMemo(() => {
+ return {
+ getValue: (key) => rowData[key as string] ?? '',
+ setValue: (key, value) => onFieldChange(rowIndex, key as string, value),
+ // Route error lookups to parent with full path (e.g., contacts.0.email)
+ getError: (key) => {
+ const fullPath = `${arrayKey}.${rowIndex}.${key as string}`;
+ return parentContext.getError(fullPath as keyof FormValues);
+ },
+ // Route error setting to parent with full path
+ setError: (key, error) => {
+ const fullPath = `${arrayKey}.${rowIndex}.${key as string}`;
+ parentContext.setError(fullPath as keyof FormValues, error);
+ },
+ // Check both local touched state and parent touched state
+ getTouched: (key) => {
+ const fullPath = `${arrayKey}.${rowIndex}.${key as string}`;
+ return (
+ localTouched.has(key as string) || parentContext.getTouched(fullPath as keyof FormValues)
+ );
+ },
+ // Set touched in both local state (for immediate feedback) and parent context
+ setTouched: (key, touched) => {
+ if (touched) {
+ setLocalTouched((prev) => new Set(prev).add(key as string));
+ // Also set touched in parent with full path
+ const fullPath = `${arrayKey}.${rowIndex}.${key as string}`;
+ parentContext.setTouched(fullPath as keyof FormValues, true);
+ }
+ // Mark the array itself as touched
+ parentContext.setTouched(arrayKey as keyof FormValues, true);
+ },
+ getValues: () => rowData as FormValues,
+ isSubmitting: parentContext.isSubmitting,
+ isValid: true, // Individual fields determine their own validity
+ };
+ }, [rowData, rowIndex, arrayKey, onFieldChange, localTouched, parentContext]);
+
+ // If parent is disabled, provide a context that reports disabled
+ const finalContext = useMemo(() => {
+ if (!isDisabled) return scopedContext;
+ return {
+ ...scopedContext,
+ getValues: () => ({ ...rowData, _disabled: true }) as FormValues,
+ };
+ }, [scopedContext, isDisabled, rowData]);
+
+ return {children};
+}
+
+/**
+ * Icons for the ArrayField UI
+ */
+const ChevronUpIcon = (): JSX.Element => (
+
+);
+
+const ChevronDownIcon = (): JSX.Element => (
+
+);
+
+const TrashIcon = (): JSX.Element => (
+
+);
+
+const PlusIcon = (): JSX.Element => (
+
+);
+
+/**
+ * ArrayField component for repeatable field groups
+ * Follows WCAG 2.1 AA accessibility requirements
+ *
+ * Features:
+ * - Collapsible rows with summary
+ * - Reuses existing Field components for full feature support
+ * - Each nested field handles its own validation
+ * - Dropdowns/popovers float above row containers
+ * - Empty state with call-to-action
+ * - Confirmation before remove (optional)
+ * - Keyboard accessible
+ */
+export default function ArrayField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getValues } = useFormKitContext();
+ const { t } = useI18n();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+ const liveRegionId = `${fieldId}-live`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const showError = !!error;
+
+ // Track collapsed rows
+ const [collapsedRows, setCollapsedRows] = useState>(new Set());
+ // Track pending remove confirmation
+ const [confirmingRemove, setConfirmingRemove] = useState(null);
+ // Live region announcements
+ const [announcement, setAnnouncement] = useState('');
+ const announcementTimerRef = useRef | null>(null);
+
+ // Ensure value is an array
+ const rows = useMemo(() => (Array.isArray(value) ? value : []), [value]);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Config options with defaults
+ const minRows = config.minRows ?? 0;
+ const maxRows = config.maxRows ?? Infinity;
+ const collapsible = config.collapsible ?? false;
+ const confirmRemove = config.confirmRemove ?? false;
+
+ // Computed constraints
+ const canAdd = rows.length < maxRows && !isDisabled;
+ const canRemove = rows.length > minRows && !isDisabled;
+
+ // Labels
+ const addLabel = config.addLabel ?? t('field.add');
+ const removeLabel = config.removeLabel ?? t('field.remove');
+ const emptyMessage = config.emptyMessage ?? t('array.empty');
+
+ // Announce changes for screen readers
+ const announce = useCallback((message: string) => {
+ setAnnouncement(message);
+ if (announcementTimerRef.current) {
+ clearTimeout(announcementTimerRef.current);
+ }
+ announcementTimerRef.current = setTimeout(() => {
+ setAnnouncement('');
+ announcementTimerRef.current = null;
+ }, 1000);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ if (announcementTimerRef.current) {
+ clearTimeout(announcementTimerRef.current);
+ }
+ };
+ }, []);
+
+ // Add a new row
+ const handleAdd = useCallback(() => {
+ if (!canAdd) return;
+ const newRow: Record = {};
+ config.arrayFields?.forEach((field) => {
+ newRow[field.key] = field.type === FieldType.CHECKBOX ? false : '';
+ });
+ setValue(config.key, [...rows, newRow]);
+ announce(t('array.rowAdded'));
+ }, [canAdd, rows, config.arrayFields, config.key, setValue, announce, t]);
+
+ // Remove a row
+ const handleRemove = useCallback(
+ (index: number) => {
+ if (!canRemove) return;
+
+ // If confirmation required and not yet confirming
+ if (confirmRemove && confirmingRemove !== index) {
+ setConfirmingRemove(index);
+ return;
+ }
+
+ setValue(
+ config.key,
+ rows.filter((_, i) => i !== index),
+ );
+ setConfirmingRemove(null);
+ // Update collapsed rows indices
+ setCollapsedRows((prev) => {
+ const newSet = new Set();
+ prev.forEach((i) => {
+ if (i < index) newSet.add(i);
+ else if (i > index) newSet.add(i - 1);
+ });
+ return newSet;
+ });
+ announce(t('array.rowRemoved'));
+ },
+ [canRemove, confirmRemove, confirmingRemove, rows, config.key, setValue, announce, t],
+ );
+
+ // Cancel remove confirmation
+ const cancelRemove = useCallback(() => {
+ setConfirmingRemove(null);
+ }, []);
+
+ // Toggle row collapse
+ const toggleCollapse = useCallback((index: number) => {
+ setCollapsedRows((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(index)) {
+ newSet.delete(index);
+ } else {
+ newSet.add(index);
+ }
+ return newSet;
+ });
+ }, []);
+
+ // Update a field in a row
+ const handleFieldChange = useCallback(
+ (rowIndex: number, fieldKey: string, fieldValue: FieldValue) => {
+ const newRows = rows.map((row, i) => {
+ if (i !== rowIndex) return row;
+ return { ...(row as Record), [fieldKey]: fieldValue };
+ });
+ setValue(config.key, newRows);
+ },
+ [rows, config.key, setValue],
+ );
+
+ // Get row summary for collapsed state
+ const getRowSummary = useCallback(
+ (row: Record): string => {
+ const firstField = config.arrayFields?.[0];
+ if (!firstField) return `${t('array.row')}`;
+ const val = row[firstField.key];
+ return val ? String(val) : `${t('array.row')}`;
+ },
+ [config.arrayFields, t],
+ );
+
+ // aria-describedby computation
+ const describedBy =
+ [config.description ? descId : null, showError ? errorId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ return (
+
+ );
+}
+
+export type { Props as ArrayFieldProps };
diff --git a/src/components/fields/CheckboxField.tsx b/src/components/fields/CheckboxField.tsx
new file mode 100644
index 0000000..9523e5b
--- /dev/null
+++ b/src/components/fields/CheckboxField.tsx
@@ -0,0 +1,131 @@
+/**
+ * CheckboxField - Single checkbox input
+ * Uses custom visual checkbox matching MultiSelectField style
+ */
+
+import type { JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for CheckboxField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * CheckboxField component for boolean input
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function CheckboxField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+ const isChecked = Boolean(value);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault();
+ if (!isDisabled) {
+ setValue(config.key, !isChecked);
+ }
+ }
+ };
+
+ return (
+
+
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as CheckboxFieldProps };
diff --git a/src/components/fields/DateField.tsx b/src/components/fields/DateField.tsx
new file mode 100644
index 0000000..fb05bbd
--- /dev/null
+++ b/src/components/fields/DateField.tsx
@@ -0,0 +1,528 @@
+/**
+ * DateField - Custom date picker dropdown
+ * Styled to match SelectField/MultiSelectField for consistency
+ */
+
+import { useState, useRef, useEffect, type JSX, type KeyboardEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for DateField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * DateField component for date selection
+ * Custom calendar dropdown matching SelectField styling
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function DateField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t, translations } = useI18n();
+
+ // Get translated months and weekdays
+ const MONTHS = [
+ translations.datetime.months.january,
+ translations.datetime.months.february,
+ translations.datetime.months.march,
+ translations.datetime.months.april,
+ translations.datetime.months.may,
+ translations.datetime.months.june,
+ translations.datetime.months.july,
+ translations.datetime.months.august,
+ translations.datetime.months.september,
+ translations.datetime.months.october,
+ translations.datetime.months.november,
+ translations.datetime.months.december,
+ ];
+
+ const WEEKDAYS = [
+ translations.datetime.daysShort.sun,
+ translations.datetime.daysShort.mon,
+ translations.datetime.daysShort.tue,
+ translations.datetime.daysShort.wed,
+ translations.datetime.daysShort.thu,
+ translations.datetime.daysShort.fri,
+ translations.datetime.daysShort.sat,
+ ];
+
+ const fieldId = `field-${config.key}`;
+ const dialogId = `${fieldId}-dialog`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Parse current value
+ const parseDate = (): Date | null => {
+ if (!value) return null;
+ if (value instanceof Date) return value;
+ if (typeof value !== 'string' && typeof value !== 'number') return null;
+ const d = new Date(String(value));
+ return Number.isNaN(d.getTime()) ? null : d;
+ };
+
+ const selectedDate = parseDate();
+
+ // UI state
+ const [isOpen, setIsOpen] = useState(false);
+ const [viewDate, setViewDate] = useState(() => selectedDate ?? new Date());
+ const [focusedDate, setFocusedDate] = useState(null);
+
+ const containerRef = useRef(null);
+ const calendarRef = useRef(null);
+ const mouseDownInsideRef = useRef(false);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Format date for display
+ const formatDisplayDate = (): string => {
+ if (!selectedDate) return config.placeholder ?? t('datetime.selectDate');
+ return selectedDate.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ // Format date for value (YYYY-MM-DD)
+ const formatValueDate = (date: Date): string => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
+ // Get calendar days for current view
+ const getCalendarDays = () => {
+ const year = viewDate.getFullYear();
+ const month = viewDate.getMonth();
+
+ const firstDay = new Date(year, month, 1);
+ const lastDay = new Date(year, month + 1, 0);
+ const startPadding = firstDay.getDay();
+ const totalDays = lastDay.getDate();
+
+ const days: (Date | null)[] = [];
+
+ // Previous month padding
+ for (let i = 0; i < startPadding; i++) {
+ days.push(null);
+ }
+
+ // Current month days
+ for (let i = 1; i <= totalDays; i++) {
+ days.push(new Date(year, month, i));
+ }
+
+ return days;
+ };
+
+ // Helper to open dropdown with state reset
+ const openDropdown = () => {
+ if (isDisabled || config.readOnly) return;
+ setViewDate(selectedDate ?? new Date());
+ setFocusedDate(selectedDate ?? new Date());
+ setIsOpen(true);
+ };
+
+ // Handle date selection
+ const selectDate = (date: Date) => {
+ if (isDisabled || config.readOnly) return;
+ setValue(config.key, formatValueDate(date));
+ setTouched(config.key, true);
+ setIsOpen(false);
+ };
+
+ // Handle clear
+ const clearSelection = () => {
+ if (isDisabled || config.readOnly) return;
+ setValue(config.key, '');
+ setTouched(config.key, true);
+ };
+
+ // Navigate months
+ const prevMonth = () => {
+ setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
+ };
+
+ const nextMonth = () => {
+ setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
+ };
+
+ // Check if dates are same day
+ const isSameDay = (a: Date | null, b: Date | null): boolean => {
+ if (!a || !b) return false;
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+ };
+
+ // Check if date is today
+ const isToday = (date: Date): boolean => {
+ return isSameDay(date, new Date());
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (isDisabled) return;
+
+ switch (e.key) {
+ case 'Enter':
+ case ' ':
+ e.preventDefault();
+ if (!isOpen) {
+ openDropdown();
+ } else if (focusedDate) {
+ selectDate(focusedDate);
+ }
+ break;
+
+ case 'Escape':
+ e.preventDefault();
+ setIsOpen(false);
+ break;
+
+ case 'ArrowLeft':
+ if (isOpen && focusedDate) {
+ e.preventDefault();
+ const newDate = new Date(focusedDate);
+ newDate.setDate(newDate.getDate() - 1);
+ setFocusedDate(newDate);
+ if (newDate.getMonth() !== viewDate.getMonth()) {
+ setViewDate(new Date(newDate.getFullYear(), newDate.getMonth(), 1));
+ }
+ }
+ break;
+
+ case 'ArrowRight':
+ if (isOpen && focusedDate) {
+ e.preventDefault();
+ const newDate = new Date(focusedDate);
+ newDate.setDate(newDate.getDate() + 1);
+ setFocusedDate(newDate);
+ if (newDate.getMonth() !== viewDate.getMonth()) {
+ setViewDate(new Date(newDate.getFullYear(), newDate.getMonth(), 1));
+ }
+ }
+ break;
+
+ case 'ArrowUp':
+ if (isOpen && focusedDate) {
+ e.preventDefault();
+ const newDate = new Date(focusedDate);
+ newDate.setDate(newDate.getDate() - 7);
+ setFocusedDate(newDate);
+ if (newDate.getMonth() !== viewDate.getMonth()) {
+ setViewDate(new Date(newDate.getFullYear(), newDate.getMonth(), 1));
+ }
+ }
+ break;
+
+ case 'ArrowDown':
+ e.preventDefault();
+ if (!isOpen) {
+ openDropdown();
+ } else if (focusedDate) {
+ const newDate = new Date(focusedDate);
+ newDate.setDate(newDate.getDate() + 7);
+ setFocusedDate(newDate);
+ if (newDate.getMonth() !== viewDate.getMonth()) {
+ setViewDate(new Date(newDate.getFullYear(), newDate.getMonth(), 1));
+ }
+ }
+ break;
+ }
+ };
+
+ // Track mousedown inside container to prevent blur from closing dropdown
+ useEffect(() => {
+ const handleMouseDown = (e: MouseEvent) => {
+ mouseDownInsideRef.current = containerRef.current?.contains(e.target as Node) ?? false;
+ };
+
+ const handleMouseUp = (e: MouseEvent) => {
+ // Close dropdown only if both mousedown and mouseup are outside
+ if (!mouseDownInsideRef.current && !containerRef.current?.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ mouseDownInsideRef.current = false;
+ };
+
+ document.addEventListener('mousedown', handleMouseDown);
+ document.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ document.removeEventListener('mousedown', handleMouseDown);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, []);
+
+ const calendarDays = getCalendarDays();
+ let emptyCellCounter = 0;
+
+ const getDateCellClassName = (date: Date): string => {
+ if (isSameDay(date, selectedDate)) {
+ return 'bg-blue-100 text-blue-800 text-sm font-medium';
+ }
+ if (isSameDay(date, focusedDate)) {
+ return 'bg-blue-100';
+ }
+ if (isToday(date)) {
+ return 'bg-gray-100 font-semibold';
+ }
+ return 'hover:bg-gray-100';
+ };
+
+ return (
+
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+
+ {/* Main control area */}
+
+
+ {selectedDate && !isDisabled && !config.readOnly && (
+
+ )}
+
+ {/* Calendar dropdown */}
+ {isOpen && !isDisabled && !config.readOnly && (
+
+ )}
+
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as DateFieldProps };
diff --git a/src/components/fields/DateTimeField.tsx b/src/components/fields/DateTimeField.tsx
new file mode 100644
index 0000000..873ad59
--- /dev/null
+++ b/src/components/fields/DateTimeField.tsx
@@ -0,0 +1,694 @@
+/**
+ * DateTimeField - Combined date and time picker dropdown
+ * Styled to match SelectField/MultiSelectField for consistency
+ */
+
+import { useState, useRef, useEffect, type JSX, type KeyboardEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for DateTimeField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * DateTimeField component for combined date and time selection
+ * Custom dropdown matching SelectField styling
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function DateTimeField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t, translations } = useI18n();
+
+ // Get translated months and weekdays
+ const MONTHS = [
+ translations.datetime.months.january,
+ translations.datetime.months.february,
+ translations.datetime.months.march,
+ translations.datetime.months.april,
+ translations.datetime.months.may,
+ translations.datetime.months.june,
+ translations.datetime.months.july,
+ translations.datetime.months.august,
+ translations.datetime.months.september,
+ translations.datetime.months.october,
+ translations.datetime.months.november,
+ translations.datetime.months.december,
+ ];
+
+ const WEEKDAYS = [
+ translations.datetime.daysShort.sun,
+ translations.datetime.daysShort.mon,
+ translations.datetime.daysShort.tue,
+ translations.datetime.daysShort.wed,
+ translations.datetime.daysShort.thu,
+ translations.datetime.daysShort.fri,
+ translations.datetime.daysShort.sat,
+ ];
+
+ const fieldId = `field-${config.key}`;
+ const dialogId = `${fieldId}-dialog`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Parse current value (YYYY-MM-DDTHH:mm format)
+ const parseDateTime = (): { date: Date | null; hours: number; minutes: number } => {
+ const currentValue = typeof value === 'string' ? value : '';
+ if (!currentValue) return { date: null, hours: 0, minutes: 0 };
+
+ const [datePart, timePart] = currentValue.split('T');
+ const date = datePart ? new Date(datePart) : null;
+ const [h, m] = (timePart || '').split(':').map((v) => Number.parseInt(v, 10) || 0);
+
+ return {
+ date: date && !Number.isNaN(date.getTime()) ? date : null,
+ hours: h,
+ minutes: m,
+ };
+ };
+
+ const { date: selectedDate, hours, minutes } = parseDateTime();
+
+ // UI state
+ const [isOpen, setIsOpen] = useState(false);
+ const [activeTab, setActiveTab] = useState<'date' | 'time'>('date');
+ const [viewDate, setViewDate] = useState(() => selectedDate ?? new Date());
+ const [tempDate, setTempDate] = useState(selectedDate);
+ const [selectedHour, setSelectedHour] = useState(hours);
+ const [selectedMinute, setSelectedMinute] = useState(minutes);
+
+ const containerRef = useRef(null);
+ const hourListRef = useRef(null);
+ const minuteListRef = useRef(null);
+ const mouseDownInsideRef = useRef(false);
+
+ // Config options
+ const timeStep = config.timeStep ?? 60;
+ const minuteInterval = Math.max(1, Math.floor(timeStep / 60));
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Generate hour options (0-23)
+ const hourOptions = Array.from({ length: 24 }, (_, i) => i);
+
+ // Generate minute options based on interval
+ const minuteOptions = Array.from(
+ { length: Math.ceil(60 / minuteInterval) },
+ (_, i) => i * minuteInterval,
+ ).filter((m) => m < 60);
+
+ // Format datetime for display
+ const formatDisplayDateTime = (): string => {
+ if (!selectedDate) return config.placeholder ?? t('datetime.selectDate');
+ const datePart = selectedDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+ const h = hours % 12 || 12;
+ const ampm = hours >= 12 ? translations.datetime.pm : translations.datetime.am;
+ const timePart = `${h}:${String(minutes).padStart(2, '0')} ${ampm}`;
+ return `${datePart}, ${timePart}`;
+ };
+
+ // Format datetime for value (YYYY-MM-DDTHH:mm)
+ const formatValueDateTime = (date: Date, h: number, m: number): string => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
+ };
+
+ // Helper to open dropdown with state reset
+ const openDropdown = () => {
+ if (isDisabled || config.readOnly) return;
+ setViewDate(selectedDate ?? new Date());
+ setTempDate(selectedDate);
+ setSelectedHour(hours);
+ setSelectedMinute(minutes);
+ setActiveTab('date');
+ setIsOpen(true);
+ };
+
+ // Get calendar days for current view
+ const getCalendarDays = () => {
+ const year = viewDate.getFullYear();
+ const month = viewDate.getMonth();
+
+ const firstDay = new Date(year, month, 1);
+ const lastDay = new Date(year, month + 1, 0);
+ const startPadding = firstDay.getDay();
+ const totalDays = lastDay.getDate();
+
+ const days: (Date | null)[] = [];
+
+ for (let i = 0; i < startPadding; i++) {
+ days.push(null);
+ }
+
+ for (let i = 1; i <= totalDays; i++) {
+ days.push(new Date(year, month, i));
+ }
+
+ return days;
+ };
+
+ // Check if dates are same day
+ const isSameDay = (a: Date | null, b: Date | null): boolean => {
+ if (!a || !b) return false;
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+ };
+
+ // Check if date is today
+ const isToday = (date: Date): boolean => {
+ return isSameDay(date, new Date());
+ };
+
+ // Handle date selection in calendar
+ const selectCalendarDate = (date: Date) => {
+ setTempDate(date);
+ setActiveTab('time');
+ };
+
+ // Handle confirm
+ const confirmSelection = () => {
+ if (!tempDate) return;
+ setValue(config.key, formatValueDateTime(tempDate, selectedHour, selectedMinute));
+ setTouched(config.key, true);
+ setIsOpen(false);
+ };
+
+ // Handle clear
+ const clearSelection = () => {
+ if (isDisabled || config.readOnly) return;
+ setValue(config.key, '');
+ setTouched(config.key, true);
+ };
+
+ // Navigate months
+ const prevMonth = () => {
+ setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
+ };
+
+ const nextMonth = () => {
+ setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (isDisabled) return;
+
+ switch (e.key) {
+ case 'Enter':
+ case ' ':
+ e.preventDefault();
+ if (!isOpen) {
+ openDropdown();
+ }
+ break;
+
+ case 'Escape':
+ e.preventDefault();
+ setIsOpen(false);
+ break;
+
+ case 'ArrowDown':
+ e.preventDefault();
+ if (!isOpen) {
+ openDropdown();
+ }
+ break;
+ }
+ };
+
+ // Track mousedown inside container to prevent blur from closing dropdown
+ useEffect(() => {
+ const handleMouseDown = (e: MouseEvent) => {
+ mouseDownInsideRef.current = containerRef.current?.contains(e.target as Node) ?? false;
+ };
+
+ const handleMouseUp = (e: MouseEvent) => {
+ // Close dropdown only if both mousedown and mouseup are outside
+ if (!mouseDownInsideRef.current && !containerRef.current?.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ mouseDownInsideRef.current = false;
+ };
+
+ document.addEventListener('mousedown', handleMouseDown);
+ document.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ document.removeEventListener('mousedown', handleMouseDown);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, []);
+
+ // Scroll selected time into view
+ useEffect(() => {
+ if (isOpen && activeTab === 'time' && hourListRef.current) {
+ const selectedEl = hourListRef.current.querySelector('[aria-selected="true"]');
+ if (selectedEl?.scrollIntoView) {
+ selectedEl.scrollIntoView({ block: 'center' });
+ }
+ }
+ }, [isOpen, activeTab, selectedHour]);
+
+ useEffect(() => {
+ if (isOpen && activeTab === 'time' && minuteListRef.current) {
+ const selectedEl = minuteListRef.current.querySelector('[aria-selected="true"]');
+ if (selectedEl?.scrollIntoView) {
+ selectedEl.scrollIntoView({ block: 'center' });
+ }
+ }
+ }, [isOpen, activeTab, selectedMinute]);
+
+ const calendarDays = getCalendarDays();
+ let emptyCellCounter = 0;
+
+ return (
+
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+
+ {/* Main control area */}
+
+
+ {selectedDate && !isDisabled && !config.readOnly && (
+
+ )}
+
+ {/* DateTime picker dropdown */}
+ {isOpen && !isDisabled && !config.readOnly && (
+
+ )}
+
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as DateTimeFieldProps };
diff --git a/src/components/fields/Field.tsx b/src/components/fields/Field.tsx
new file mode 100644
index 0000000..482d8cb
--- /dev/null
+++ b/src/components/fields/Field.tsx
@@ -0,0 +1,131 @@
+/**
+ * Field - Universal field router
+ * Reads field type and delegates to the appropriate field component
+ */
+
+import type { JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { FieldType } from '../../core/types';
+import { isFieldVisible } from '../../core/conditional';
+import { useFormKitContext } from '../context/FormKitContext';
+import TextField from './TextField';
+import PasswordField from './PasswordField';
+import TextareaField from './TextareaField';
+import SelectField from './SelectField';
+import MultiSelectField from './MultiSelectField';
+import CheckboxField from './CheckboxField';
+import RadioGroupField from './RadioGroupField';
+import SwitchField from './SwitchField';
+import DateField from './DateField';
+import FileField from './FileField';
+import PhoneField from './PhoneField';
+import SliderField from './SliderField';
+import RangeSliderField from './RangeSliderField';
+import OTPField from './OTPField';
+import TagsField from './TagsField';
+import RatingField from './RatingField';
+import TimeField from './TimeField';
+import DateTimeField from './DateTimeField';
+import ArrayField from './ArrayField';
+
+/**
+ * Props for Field component
+ */
+type Props = {
+ /** Field configuration */
+ config: FieldConfig;
+};
+
+/**
+ * Universal field router β reads type from config and renders the appropriate field
+ * Handles conditional visibility via showWhen/hideWhen
+ *
+ * @internal β use DynamicForm, not Field directly
+ */
+export default function Field({ config }: Readonly): JSX.Element | null {
+ const { getValues } = useFormKitContext();
+
+ // Check conditional visibility
+ const values = getValues();
+ const visible = isFieldVisible(config.showWhen, config.hideWhen, values);
+
+ if (!visible) {
+ return null;
+ }
+
+ // Column span class
+ const colSpanClass = config.colSpan ? `col-span-${config.colSpan}` : '';
+ const wrapperClass = `formkit-field ${colSpanClass} ${config.className ?? ''}`.trim();
+
+ // Route to appropriate field component based on type
+ const renderField = (): JSX.Element => {
+ switch (config.type) {
+ case FieldType.TEXT:
+ case FieldType.EMAIL:
+ case FieldType.NUMBER:
+ return ;
+
+ case FieldType.PASSWORD:
+ return ;
+
+ case FieldType.TEXTAREA:
+ return ;
+
+ case FieldType.SELECT:
+ return ;
+
+ case FieldType.MULTI_SELECT:
+ return ;
+
+ case FieldType.CHECKBOX:
+ return ;
+
+ case FieldType.RADIO:
+ return ;
+
+ case FieldType.SWITCH:
+ return ;
+
+ case FieldType.DATE:
+ return ;
+
+ case FieldType.PHONE:
+ return ;
+
+ case FieldType.FILE:
+ return ;
+
+ case FieldType.SLIDER:
+ return ;
+
+ case FieldType.RANGE_SLIDER:
+ return ;
+
+ case FieldType.OTP:
+ return ;
+
+ case FieldType.TAGS:
+ return ;
+
+ case FieldType.RATING:
+ return ;
+
+ case FieldType.TIME:
+ return ;
+
+ case FieldType.DATETIME:
+ return ;
+
+ case FieldType.ARRAY:
+ return ;
+
+ default:
+ // Fallback to text field
+ return ;
+ }
+ };
+
+ return {renderField()}
;
+}
+
+export type { Props as FieldProps };
diff --git a/src/components/fields/FileField.tsx b/src/components/fields/FileField.tsx
new file mode 100644
index 0000000..86ba69a
--- /dev/null
+++ b/src/components/fields/FileField.tsx
@@ -0,0 +1,198 @@
+/**
+ * FileField - File upload input with type and size validation
+ */
+
+import { useState, type JSX, type ChangeEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Format file size for display
+ */
+function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+}
+
+/**
+ * Validate file against accepted types
+ */
+function isValidFileType(file: File, accept: string): boolean {
+ if (!accept) return true;
+
+ const acceptedTypes = accept.split(',').map((t) => t.trim().toLowerCase());
+ const fileName = file.name.toLowerCase();
+ const fileType = file.type.toLowerCase();
+
+ return acceptedTypes.some((accepted) => {
+ // Extension match (e.g., '.pdf')
+ if (accepted.startsWith('.')) {
+ return fileName.endsWith(accepted);
+ }
+ // Wildcard MIME type (e.g., 'image/*')
+ if (accepted.endsWith('/*')) {
+ const baseType = accepted.slice(0, -2);
+ return fileType.startsWith(baseType + '/');
+ }
+ // Exact MIME type match
+ return fileType === accepted;
+ });
+}
+
+/**
+ * Props for FileField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * FileField component for file uploads
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function FileField({ config }: Props): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t } = useI18n();
+ const [fileError, setFileError] = useState(null);
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key) || fileError;
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Handle file selection
+ const handleChange = (e: ChangeEvent) => {
+ const files = e.target.files;
+ setFileError(null);
+
+ if (!files || files.length === 0) {
+ setValue(config.key, null);
+ return;
+ }
+
+ const fileList = Array.from(files);
+ const errors: string[] = [];
+
+ // Validate each file
+ for (const file of fileList) {
+ // Check file type
+ if (config.accept && !isValidFileType(file, config.accept)) {
+ errors.push(`"${file.name}" ${t('file.invalidType')}`);
+ continue;
+ }
+
+ // Check file size
+ if (config.maxFileSize && file.size > config.maxFileSize) {
+ errors.push(
+ `"${file.name}" ${t('file.exceedsMaxSize')} ${formatFileSize(config.maxFileSize)}`,
+ );
+ continue;
+ }
+ }
+
+ if (errors.length > 0) {
+ setFileError(errors.join('. '));
+ e.target.value = ''; // Clear the input
+ return;
+ }
+
+ // Store single file or array based on multiple
+ setValue(config.key, fileList.length === 1 ? fileList[0] : fileList);
+ };
+
+ // Display selected file name(s)
+ const getFileName = (): string => {
+ if (!value) return '';
+ if (value instanceof File) return value.name;
+ if (Array.isArray(value) && value.length > 0 && value[0] instanceof File) {
+ return (value as File[]).map((f) => f.name).join(', ');
+ }
+ return '';
+ };
+
+ return (
+
+
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
+
setTouched(config.key, true)}
+ className={`
+ formkit-file-input
+ w-full px-3 py-2
+ border rounded-md
+ file:mr-4 file:py-2 file:px-4
+ file:rounded-md file:border-0
+ file:text-sm file:font-medium
+ file:bg-blue-50 file:text-blue-700
+ hover:file:bg-blue-100
+ focus:outline-none focus:ring-2 ${showError ? 'focus:ring-red-500' : 'focus:ring-blue-500'}
+ ${showError ? 'border-red-500' : 'border-gray-300'}
+ ${isDisabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}
+ `}
+ />
+ {value && (
+
+ {t('file.selected')} {getFileName()}
+
+ )}
+
+ {/* File constraints hint */}
+ {(config.accept || config.maxFileSize) && !showError && (
+
+ {config.accept && (
+
+ {t('file.accepted')} {config.accept}
+
+ )}
+ {config.accept && config.maxFileSize && Β· }
+ {config.maxFileSize && (
+
+ {t('file.maxSize')} {formatFileSize(config.maxFileSize)}
+
+ )}
+
+ )}
+
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as FileFieldProps };
diff --git a/src/components/fields/MultiSelectField.tsx b/src/components/fields/MultiSelectField.tsx
new file mode 100644
index 0000000..d348712
--- /dev/null
+++ b/src/components/fields/MultiSelectField.tsx
@@ -0,0 +1,512 @@
+/**
+ * MultiSelectField - Multi-select dropdown with search, tags, and keyboard navigation
+ */
+
+import { useState, useRef, useEffect, type JSX, type KeyboardEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for MultiSelectField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+type KeyDownContext = {
+ isOpen: boolean;
+ isDisabled: boolean | undefined;
+ focusedIndex: number;
+ optionCount: number;
+ open: () => void;
+ close: () => void;
+ focusNext: () => void;
+ focusPrev: () => void;
+ focusFirst: () => void;
+ focusLast: () => void;
+ selectFocused: () => void;
+};
+
+function handleDropdownKeyDown(e: KeyboardEvent, ctx: KeyDownContext): void {
+ if (ctx.isDisabled) return;
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ if (!ctx.isOpen) {
+ ctx.open();
+ } else {
+ ctx.focusNext();
+ }
+ return;
+ case 'ArrowUp':
+ e.preventDefault();
+ if (ctx.isOpen) ctx.focusPrev();
+ return;
+ case 'Enter':
+ case ' ':
+ e.preventDefault();
+ if (ctx.isOpen && ctx.focusedIndex >= 0 && ctx.focusedIndex < ctx.optionCount) {
+ ctx.selectFocused();
+ } else if (!ctx.isOpen) {
+ ctx.open();
+ }
+ return;
+ case 'Escape':
+ e.preventDefault();
+ ctx.close();
+ return;
+ case 'Home':
+ e.preventDefault();
+ if (ctx.isOpen) ctx.focusFirst();
+ return;
+ case 'End':
+ e.preventDefault();
+ if (ctx.isOpen) ctx.focusLast();
+ return;
+ default:
+ return;
+ }
+}
+
+/**
+ * MultiSelectField component for selecting multiple options
+ * Features: searchable dropdown, tag display, clear all, keyboard navigation
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function MultiSelectField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t } = useI18n();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+ const listboxId = `${fieldId}-listbox`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Parse value as array, filtering out undefined/null values
+ const selectedValues: (string | number)[] = Array.isArray(value)
+ ? (value.filter((v) => v !== undefined && v !== null) as (string | number)[])
+ : [];
+
+ // UI state
+ const [isOpen, setIsOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [focusedIndex, setFocusedIndex] = useState(-1);
+
+ const containerRef = useRef(null);
+ const searchInputRef = useRef(null);
+ const listboxRef = useRef(null);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Filter options based on search
+ const options = config.options ?? [];
+ const filteredOptions = searchQuery
+ ? options.filter((opt) => opt.label.toLowerCase().includes(searchQuery.toLowerCase()))
+ : options;
+
+ // Get selected options for tag display
+ const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value));
+
+ // Handle option toggle
+ const toggleOption = (optionValue: string | number) => {
+ if (isDisabled) return;
+
+ const newValues = selectedValues.includes(optionValue)
+ ? selectedValues.filter((v) => v !== optionValue)
+ : [...selectedValues, optionValue];
+
+ setValue(config.key, newValues);
+ setTouched(config.key, true);
+ };
+
+ // Handle remove tag
+ const removeTag = (optionValue: string | number) => {
+ if (isDisabled) return;
+ setValue(
+ config.key,
+ selectedValues.filter((v) => v !== optionValue),
+ );
+ setTouched(config.key, true);
+ };
+
+ // Handle clear all
+ const clearAll = () => {
+ if (isDisabled) return;
+ setValue(config.key, []);
+ setTouched(config.key, true);
+ };
+
+ const closeDropdown = () => {
+ setIsOpen(false);
+ setSearchQuery('');
+ setFocusedIndex(-1);
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (e: KeyboardEvent) => {
+ handleDropdownKeyDown(e, {
+ isOpen,
+ isDisabled,
+ focusedIndex,
+ optionCount: filteredOptions.length,
+ open: () => {
+ setIsOpen(true);
+ setFocusedIndex(0);
+ },
+ close: closeDropdown,
+ focusNext: () => setFocusedIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1)),
+ focusPrev: () => setFocusedIndex((prev) => Math.max(prev - 1, 0)),
+ focusFirst: () => setFocusedIndex(0),
+ focusLast: () => setFocusedIndex(filteredOptions.length - 1),
+ selectFocused: () => {
+ const focusedOption = filteredOptions[focusedIndex];
+ if (!focusedOption) return;
+ toggleOption(focusedOption.value);
+ },
+ });
+ };
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setIsOpen(false);
+ setSearchQuery('');
+ setFocusedIndex(-1);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // Scroll focused option into view
+ useEffect(() => {
+ if (isOpen && focusedIndex >= 0 && listboxRef.current) {
+ const focusedElement = listboxRef.current.children[focusedIndex] as HTMLElement;
+ // Guard for jsdom which doesn't have scrollIntoView
+ if (focusedElement?.scrollIntoView) {
+ focusedElement.scrollIntoView({ block: 'nearest' });
+ }
+ }
+ }, [focusedIndex, isOpen]);
+
+ // Focus search input when dropdown opens
+ useEffect(() => {
+ if (isOpen && searchInputRef.current) {
+ searchInputRef.current.focus();
+ }
+ }, [isOpen]);
+
+ return (
+
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+
+ {/* Main control area */}
+
= 0 ? `${fieldId}-option-${focusedIndex}` : undefined
+ }
+ tabIndex={isDisabled ? -1 : 0}
+ onKeyDown={handleKeyDown}
+ onClick={() => !isDisabled && setIsOpen(!isOpen)}
+ onBlur={(e) => {
+ // Only close if focus leaves the entire component
+ if (!containerRef.current?.contains(e.relatedTarget as Node)) {
+ setIsOpen(false);
+ setSearchQuery('');
+ setTouched(config.key, true);
+ }
+ }}
+ className={`
+ formkit-multiselect-control
+ min-h-[42px] px-3 py-2
+ border rounded-lg
+ cursor-pointer
+ transition-colors duration-150
+ focus:outline-none focus:ring-2 ${showError ? 'focus:ring-red-500' : 'focus:ring-blue-500'}
+ ${showError ? 'border-red-500 hover:border-red-400' : 'border-gray-300 hover:border-gray-400'}
+ ${isDisabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}
+ ${isOpen ? (showError ? 'ring-2 ring-red-500 border-red-500' : 'ring-2 ring-blue-500 border-blue-500') : ''}
+ `}
+ >
+
+ {/* Selected tags */}
+ {selectedOptions.length > 0 ? (
+ selectedOptions.map((option) => (
+
+ {option.label}
+ {!isDisabled && (
+
+ )}
+
+ ))
+ ) : (
+
{config.placeholder ?? t('field.selectOption')}
+ )}
+
+ {/* Spacer and controls */}
+
+
+ {/* Clear all button */}
+ {selectedOptions.length > 0 && !isDisabled && (
+
+ )}
+
+ {/* Dropdown arrow */}
+
+
+
+
+ {/* Dropdown */}
+ {isOpen && !isDisabled && (
+
+ {/* Search input */}
+
+ {
+ setSearchQuery(e.target.value);
+ setFocusedIndex(0);
+ }}
+ onKeyDown={(e) => {
+ // Handle keyboard in search input
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ setIsOpen(false);
+ setSearchQuery('');
+ setFocusedIndex(-1);
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setFocusedIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
+ } else if (
+ e.key === 'Enter' &&
+ focusedIndex >= 0 &&
+ focusedIndex < filteredOptions.length
+ ) {
+ e.preventDefault();
+ toggleOption(filteredOptions[focusedIndex].value);
+ }
+ }}
+ placeholder={t('field.search')}
+ aria-label={t('field.search')}
+ className="
+ w-full px-3 py-1.5
+ text-sm
+ border border-gray-300 rounded-md
+ focus:outline-none
+ "
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+ {/* Options list */}
+
+ {filteredOptions.length === 0 ? (
+ -
+ {t('field.noOptionsFound')}
+
+ ) : (
+ filteredOptions.map((option, index) => {
+ const isSelected = selectedValues.includes(option.value);
+ const isFocused = focusedIndex === index;
+
+ return (
+ - setFocusedIndex(index)}
+ className="px-0"
+ >
+
+
+ );
+ })
+ )}
+
+
+ {/* Selection count */}
+ {selectedValues.length > 0 && (
+
+ {selectedValues.length} {t('field.selected')}
+
+ )}
+
+ )}
+
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as MultiSelectFieldProps };
diff --git a/src/components/fields/OTPField.tsx b/src/components/fields/OTPField.tsx
new file mode 100644
index 0000000..0572a98
--- /dev/null
+++ b/src/components/fields/OTPField.tsx
@@ -0,0 +1,214 @@
+/**
+ * OTPField - One-Time Password / Verification code input
+ */
+
+import { useRef, useState, type JSX, type KeyboardEvent, type ClipboardEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for OTPField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * OTPField component for verification code input
+ * Supports 4-6 digit codes with auto-advance and paste support
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function OTPField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // OTP config with defaults
+ const length = config.otpLength ?? 6;
+
+ // Parse current value into array of digits
+ const currentValue = typeof value === 'string' ? value : '';
+ const digits = currentValue.split('').slice(0, length);
+ while (digits.length < length) {
+ digits.push('');
+ }
+
+ // Track focused index for styling
+ const [focusedIndex, setFocusedIndex] = useState(null);
+
+ // Refs for each input
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Update the full OTP value
+ const updateValue = (newDigits: string[]) => {
+ const newValue = newDigits.join('');
+ setValue(config.key, newValue);
+ };
+
+ // Handle single digit input
+ const handleInput = (index: number, inputValue: string) => {
+ // Only allow single digit
+ const digit = inputValue.replace(/\D/g, '').slice(-1);
+
+ const newDigits = [...digits];
+ newDigits[index] = digit;
+ updateValue(newDigits);
+
+ // Auto-advance to next input
+ if (digit && index < length - 1) {
+ inputRefs.current[index + 1]?.focus();
+ }
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (index: number, e: KeyboardEvent) => {
+ switch (e.key) {
+ case 'Backspace':
+ if (!digits[index] && index > 0) {
+ // Move to previous input on backspace when current is empty
+ inputRefs.current[index - 1]?.focus();
+ const newDigits = [...digits];
+ newDigits[index - 1] = '';
+ updateValue(newDigits);
+ e.preventDefault();
+ } else {
+ // Clear current
+ const newDigits = [...digits];
+ newDigits[index] = '';
+ updateValue(newDigits);
+ }
+ break;
+
+ case 'ArrowLeft':
+ if (index > 0) {
+ inputRefs.current[index - 1]?.focus();
+ e.preventDefault();
+ }
+ break;
+
+ case 'ArrowRight':
+ if (index < length - 1) {
+ inputRefs.current[index + 1]?.focus();
+ e.preventDefault();
+ }
+ break;
+
+ case 'Delete': {
+ const newDigits = [...digits];
+ newDigits[index] = '';
+ updateValue(newDigits);
+ break;
+ }
+ }
+ };
+
+ // Handle paste - fill all inputs from clipboard
+ const handlePaste = (e: ClipboardEvent) => {
+ e.preventDefault();
+ const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length);
+
+ if (pastedData) {
+ const newDigits = pastedData.split('');
+ while (newDigits.length < length) {
+ newDigits.push('');
+ }
+ updateValue(newDigits);
+
+ // Focus last filled input or last input
+ const lastFilledIndex = Math.min(pastedData.length, length) - 1;
+ inputRefs.current[lastFilledIndex]?.focus();
+ }
+ };
+
+ // Check if OTP is complete
+ const isComplete = digits.every((d) => d !== '');
+
+ const getInputStateClass = (digit: string, index: number): string => {
+ if (showError) return 'border-red-500';
+ if (digit || focusedIndex === index) return 'border-blue-500';
+ return 'border-gray-300 hover:border-gray-400';
+ };
+
+ return (
+
+
+
+
+ {digits.map((digit, index) => (
+ {
+ inputRefs.current[index] = el;
+ }}
+ id={`${fieldId}-${index}`}
+ type="text"
+ inputMode="numeric"
+ pattern="[0-9]"
+ maxLength={1}
+ value={digit}
+ disabled={isDisabled}
+ aria-label={`Digit ${index + 1} of ${length}`}
+ aria-invalid={showError}
+ aria-describedby={index === 0 ? describedBy : undefined}
+ onChange={(e) => handleInput(index, e.target.value)}
+ onKeyDown={(e) => handleKeyDown(index, e)}
+ onPaste={handlePaste}
+ onFocus={() => {
+ setFocusedIndex(index);
+ // Select content on focus
+ inputRefs.current[index]?.select();
+ }}
+ onBlur={() => {
+ setFocusedIndex(null);
+ setTouched(config.key, true);
+ }}
+ className={`
+ formkit-otp-input
+ flex-1 min-w-0 max-w-12 sm:max-w-14
+ aspect-square sm:aspect-auto sm:h-14
+ text-center text-lg sm:text-2xl font-semibold
+ border-2 rounded-lg
+ transition-all duration-150
+ focus:outline-none
+ ${getInputStateClass(digit, index)}
+ ${isComplete && !showError ? 'border-green-500 bg-green-50' : 'bg-white'}
+ ${isDisabled ? 'bg-gray-100 cursor-not-allowed opacity-50' : ''}
+ `}
+ />
+ ))}
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as OTPFieldProps };
diff --git a/src/components/fields/PasswordField.tsx b/src/components/fields/PasswordField.tsx
new file mode 100644
index 0000000..6b35273
--- /dev/null
+++ b/src/components/fields/PasswordField.tsx
@@ -0,0 +1,157 @@
+/**
+ * PasswordField - Password input with show/hide toggle
+ */
+
+import { useState, type JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for PasswordField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * PasswordField component with visibility toggle
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function PasswordField({ config }: Props): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t } = useI18n();
+ const [showPassword, setShowPassword] = useState(false);
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+ const toggleId = `${fieldId}-toggle`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+ const showSuccess = touched && !error && !!value;
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ return (
+
+
+
+
+
setValue(config.key, e.target.value)}
+ onBlur={() => setTouched(config.key, true)}
+ className={`
+ formkit-input
+ w-full px-3 py-2 sm:px-4 sm:py-2.5 pr-12
+ text-sm sm:text-base
+ border rounded-md
+ transition-all duration-150
+ focus:outline-none focus:ring-2 focus:border-blue-500
+ ${
+ showError
+ ? 'border-red-500 focus:ring-red-500 focus:border-red-500 hover:border-red-400'
+ : showSuccess
+ ? 'border-green-500 focus:ring-green-500 focus:border-green-500 hover:border-green-400'
+ : 'border-gray-300 focus:ring-blue-500 hover:border-gray-400'
+ }
+ ${
+ isDisabled
+ ? 'bg-gray-100 text-gray-500 cursor-not-allowed hover:border-gray-300'
+ : 'bg-white'
+ }
+ `}
+ />
+
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as PasswordFieldProps };
diff --git a/src/components/fields/PhoneField.tsx b/src/components/fields/PhoneField.tsx
new file mode 100644
index 0000000..d3ac412
--- /dev/null
+++ b/src/components/fields/PhoneField.tsx
@@ -0,0 +1,281 @@
+/**
+ * PhoneField - Phone number input with country code selector
+ */
+
+import { useState, type JSX, type ChangeEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+import countriesData from '../../data/countries.json';
+
+/**
+ * Country code option
+ */
+type CountryCode = {
+ code: string;
+ name: string;
+ dialCode: string;
+};
+
+/**
+ * Flag CDN base URL
+ */
+const FLAG_CDN = 'https://flagcdn.com';
+
+/**
+ * Get flag image URL for a country code
+ */
+function getFlagUrl(code: string, size: 'sm' | 'md' = 'sm'): string {
+ const width = size === 'sm' ? 20 : 40;
+ return `${FLAG_CDN}/w${width}/${code.toLowerCase()}.png`;
+}
+
+/**
+ * All country codes from JSON data
+ */
+const COUNTRY_CODES: CountryCode[] = countriesData.countries;
+
+/**
+ * Props for PhoneField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * Phone value structure
+ */
+type PhoneValue = {
+ countryCode: string;
+ dialCode: string;
+ number: string;
+};
+
+/**
+ * Parse stored value into PhoneValue
+ */
+function parsePhoneValue(value: unknown): PhoneValue {
+ if (typeof value === 'object' && value !== null) {
+ const v = value as Partial;
+ return {
+ countryCode: v.countryCode ?? 'US',
+ dialCode: v.dialCode ?? '+1',
+ number: v.number ?? '',
+ };
+ }
+ // If it's just a string, treat it as the number with default country
+ if (typeof value === 'string') {
+ return { countryCode: 'US', dialCode: '+1', number: value };
+ }
+ return { countryCode: 'US', dialCode: '+1', number: '' };
+}
+
+/**
+ * PhoneField component for phone number input with country selection
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function PhoneField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t } = useI18n();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const fieldId = `field-${config.key}`;
+ const countrySelectId = `${fieldId}-country`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const rawValue = getValue(config.key);
+ const phoneValue = parsePhoneValue(rawValue);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+ const showSuccess = touched && !error && !!phoneValue.number;
+
+ // Find current country
+ const currentCountry =
+ COUNTRY_CODES.find((c) => c.code === phoneValue.countryCode) ?? COUNTRY_CODES[0];
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ const phoneBorderClass = showError
+ ? 'border-red-500 focus:ring-red-500 focus:border-red-500 hover:border-red-400'
+ : showSuccess
+ ? 'border-green-500 focus:ring-green-500 focus:border-green-500 hover:border-green-400'
+ : 'border-gray-300 focus:ring-blue-500 hover:border-gray-400';
+
+ const phoneDisabledClass = isDisabled
+ ? 'bg-gray-100 text-gray-500 cursor-not-allowed hover:border-gray-300'
+ : 'bg-white';
+
+ // Handle country change
+ const handleCountryChange = (country: CountryCode) => {
+ setValue(config.key, {
+ countryCode: country.code,
+ dialCode: country.dialCode,
+ number: phoneValue.number,
+ });
+ setIsOpen(false);
+ };
+
+ // Handle phone number change
+ const handleNumberChange = (e: ChangeEvent) => {
+ // Only allow digits, spaces, dashes, and parentheses
+ const cleaned = e.target.value.replace(/[^\d\s\-()]/g, '');
+ setValue(config.key, {
+ countryCode: phoneValue.countryCode,
+ dialCode: phoneValue.dialCode,
+ number: cleaned,
+ });
+ };
+
+ return (
+
+
+
+
+ {/* Country selector */}
+
+
+
+ {/* Country dropdown */}
+ {isOpen && !isDisabled && (
+
+ {COUNTRY_CODES.map((country) => (
+ - handleCountryChange(country)}
+ className={`
+ flex items-center gap-2 px-3 py-2
+ cursor-pointer
+ hover:bg-blue-50
+ ${country.code === phoneValue.countryCode ? 'bg-blue-100' : ''}
+ `}
+ >
+
+ {country.name}
+ {country.dialCode}
+
+ ))}
+
+ )}
+
+
+ {/* Phone number input */}
+
setTouched(config.key, true)}
+ className={`
+ formkit-phone-input
+ formkit-field
+ flex-1 px-3 py-2 sm:px-4 sm:py-2.5
+ text-sm sm:text-base
+ border rounded-r-md
+ transition-all duration-150
+ focus:outline-none focus:ring-2 focus:border-blue-500
+ ${phoneBorderClass}
+ ${phoneDisabledClass}
+ `}
+ />
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as PhoneFieldProps, PhoneValue, CountryCode };
diff --git a/src/components/fields/RadioGroupField.tsx b/src/components/fields/RadioGroupField.tsx
new file mode 100644
index 0000000..3732b9f
--- /dev/null
+++ b/src/components/fields/RadioGroupField.tsx
@@ -0,0 +1,105 @@
+/**
+ * RadioGroupField - Radio button group
+ */
+
+import type { JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for RadioGroupField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * RadioGroupField component for selecting one option from a group
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function RadioGroupField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ return (
+
+ );
+}
+
+export type { Props as RadioGroupFieldProps };
diff --git a/src/components/fields/RangeSliderField.tsx b/src/components/fields/RangeSliderField.tsx
new file mode 100644
index 0000000..42ed7f2
--- /dev/null
+++ b/src/components/fields/RangeSliderField.tsx
@@ -0,0 +1,276 @@
+/**
+ * RangeSliderField - Dual-thumb range slider for selecting a range
+ */
+
+import type { JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Range value type
+ */
+export type RangeValue = {
+ from: number;
+ to: number;
+};
+
+/**
+ * Props for RangeSliderField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * Parse stored value into RangeValue
+ */
+function parseRangeValue(value: unknown, min: number, max: number): RangeValue {
+ if (typeof value === 'object' && value !== null) {
+ const v = value as Partial;
+ return {
+ from: typeof v.from === 'number' ? v.from : min,
+ to: typeof v.to === 'number' ? v.to : max,
+ };
+ }
+ return { from: min, to: max };
+}
+
+/**
+ * RangeSliderField component for selecting a numeric range
+ * Uses two overlapping range inputs for dual-thumb functionality
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function RangeSliderField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+
+ const fieldId = `field-${config.key}`;
+ const fromId = `${fieldId}-from`;
+ const toId = `${fieldId}-to`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const rawValue = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Slider config with defaults
+ const min = config.min ?? 0;
+ const max = config.max ?? 100;
+ const step = config.step ?? 1;
+ const showValue = config.showValue !== false;
+
+ // Current range values
+ const rangeValue = parseRangeValue(rawValue, min, max);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Calculate percentages for track fill
+ const fromPercent = ((rangeValue.from - min) / (max - min)) * 100;
+ const toPercent = ((rangeValue.to - min) / (max - min)) * 100;
+
+ // Handle from slider change
+ const handleFromChange = (newFrom: number) => {
+ // Don't allow from to exceed to
+ const clampedFrom = Math.min(newFrom, rangeValue.to - step);
+ setValue(config.key, { from: Math.max(clampedFrom, min), to: rangeValue.to });
+ };
+
+ // Handle to slider change
+ const handleToChange = (newTo: number) => {
+ // Don't allow to to go below from
+ const clampedTo = Math.max(newTo, rangeValue.from + step);
+ setValue(config.key, { from: rangeValue.from, to: Math.min(clampedTo, max) });
+ };
+
+ // Handle number input change with clamping
+ const handleFromInputChange = (inputValue: string) => {
+ const parsed = Number(inputValue);
+ if (Number.isNaN(parsed)) return;
+ const clamped = Math.min(Math.max(parsed, min), rangeValue.to - step);
+ setValue(config.key, { from: clamped, to: rangeValue.to });
+ };
+
+ const handleToInputChange = (inputValue: string) => {
+ const parsed = Number(inputValue);
+ if (Number.isNaN(parsed)) return;
+ const clamped = Math.max(Math.min(parsed, max), rangeValue.from + step);
+ setValue(config.key, { from: rangeValue.from, to: clamped });
+ };
+
+ // Shared thumb styles
+ const thumbStyles = `
+ [&::-webkit-slider-thumb]:appearance-none
+ [&::-webkit-slider-thumb]:h-5
+ [&::-webkit-slider-thumb]:w-5
+ [&::-webkit-slider-thumb]:rounded-full
+ [&::-webkit-slider-thumb]:bg-white
+ [&::-webkit-slider-thumb]:border-2
+ [&::-webkit-slider-thumb]:border-blue-600
+ [&::-webkit-slider-thumb]:shadow-md
+ [&::-webkit-slider-thumb]:cursor-pointer
+ [&::-webkit-slider-thumb]:transition-all
+ [&::-webkit-slider-thumb]:duration-150
+ [&::-webkit-slider-thumb]:hover:scale-110
+ [&::-webkit-slider-thumb]:relative
+ [&::-webkit-slider-thumb]:z-30
+ [&::-moz-range-thumb]:h-5
+ [&::-moz-range-thumb]:w-5
+ [&::-moz-range-thumb]:rounded-full
+ [&::-moz-range-thumb]:bg-white
+ [&::-moz-range-thumb]:border-2
+ [&::-moz-range-thumb]:border-blue-600
+ [&::-moz-range-thumb]:shadow-md
+ [&::-moz-range-thumb]:cursor-pointer
+ [&::-moz-range-thumb]:border-0
+ ${showError ? '[&::-webkit-slider-thumb]:border-red-500 [&::-moz-range-thumb]:border-red-500' : ''}
+ `;
+
+ const inputNumberStyles = `
+ formkit-range-value
+ w-16 text-center text-sm font-medium
+ text-blue-600 bg-blue-50
+ px-2 py-0.5 rounded
+ border border-blue-200
+ focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
+ [appearance:textfield]
+ [&::-webkit-outer-spin-button]:appearance-none
+ [&::-webkit-inner-spin-button]:appearance-none
+ ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
+ `;
+
+ return (
+
+
+
+
+
+ {showValue && (
+
+ handleFromInputChange(e.target.value)}
+ onBlur={() => setTouched(config.key, true)}
+ className={inputNumberStyles}
+ />
+ handleToInputChange(e.target.value)}
+ onBlur={() => setTouched(config.key, true)}
+ className={inputNumberStyles}
+ />
+
+ )}
+
+ {/* Dual range slider container */}
+
+ {/* Track background */}
+
+
+ {/* Active range highlight */}
+
+
+ {/* From slider */}
+
handleFromChange(Number(e.target.value))}
+ onBlur={() => setTouched(config.key, true)}
+ className={`
+ formkit-range-slider-from
+ absolute top-0 w-full h-6
+ appearance-none bg-transparent
+ pointer-events-none
+ [&::-webkit-slider-thumb]:pointer-events-auto
+ [&::-moz-range-thumb]:pointer-events-auto
+ ${thumbStyles}
+ ${isDisabled ? 'opacity-50' : ''}
+ `}
+ />
+
+ {/* To slider */}
+
handleToChange(Number(e.target.value))}
+ onBlur={() => setTouched(config.key, true)}
+ className={`
+ formkit-range-slider-to
+ absolute top-0 w-full h-6
+ appearance-none bg-transparent
+ pointer-events-none
+ [&::-webkit-slider-thumb]:pointer-events-auto
+ [&::-moz-range-thumb]:pointer-events-auto
+ ${thumbStyles}
+ ${isDisabled ? 'opacity-50' : ''}
+ `}
+ />
+
+
+ {/* Min/Max labels */}
+
+ {min}
+ {max}
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as RangeSliderFieldProps };
diff --git a/src/components/fields/RatingField.tsx b/src/components/fields/RatingField.tsx
new file mode 100644
index 0000000..d73f156
--- /dev/null
+++ b/src/components/fields/RatingField.tsx
@@ -0,0 +1,246 @@
+/**
+ * RatingField - Star rating input component
+ */
+
+import { useState, type JSX, type KeyboardEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for RatingField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * Star icon component
+ */
+function StarIcon({ filled, half }: { filled: boolean; half?: boolean }): JSX.Element {
+ if (half) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * RatingField component for star-based rating input
+ * Supports configurable max stars, half-star precision, and keyboard navigation
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function RatingField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t } = useI18n();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Parse numeric value
+ const currentRating = typeof value === 'number' ? value : 0;
+
+ // Config options with defaults
+ const maxRating = config.maxRating ?? 5;
+ const allowHalf = config.allowHalf ?? false;
+ const step = allowHalf ? 0.5 : 1;
+
+ // Hover state for preview
+ const [hoverRating, setHoverRating] = useState(null);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Display rating (hover preview or actual value)
+ const displayRating = hoverRating ?? currentRating;
+
+ // Handle star click
+ const handleStarClick = (rating: number) => {
+ if (isDisabled) return;
+
+ // Toggle off if clicking current rating
+ if (rating === currentRating) {
+ setValue(config.key, 0);
+ } else {
+ setValue(config.key, rating);
+ }
+ setTouched(config.key, true);
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (isDisabled) return;
+
+ let newRating: number | null = null;
+
+ switch (e.key) {
+ case 'ArrowRight':
+ case 'ArrowUp':
+ e.preventDefault();
+ newRating = Math.min(currentRating + step, maxRating);
+ break;
+ case 'ArrowLeft':
+ case 'ArrowDown':
+ e.preventDefault();
+ newRating = Math.max(currentRating - step, 0);
+ break;
+ case 'Home':
+ e.preventDefault();
+ newRating = 0;
+ break;
+ case 'End':
+ e.preventDefault();
+ newRating = maxRating;
+ break;
+ default:
+ return;
+ }
+
+ if (newRating === null) return;
+
+ setValue(config.key, newRating);
+ setTouched(config.key, true);
+ };
+
+ // Generate stars array
+ const stars = Array.from({ length: maxRating }, (_, i) => i + 1);
+
+ return (
+
+
+
+
setHoverRating(null)}
+ onBlur={() => setTouched(config.key, true)}
+ className={`
+ formkit-rating-stars
+ inline-flex gap-1
+ focus:outline-none
+ rounded
+ ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
+ `}
+ >
+ {stars.map((starValue) => {
+ const filled = displayRating >= starValue;
+ const halfFilled =
+ allowHalf && displayRating >= starValue - 0.5 && displayRating < starValue;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Rating value display */}
+
+ {currentRating > 0 ? (
+ <>
+ {currentRating}
+ / {maxRating}
+ >
+ ) : (
+ {t('rating.noRating')}
+ )}
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as RatingFieldProps };
diff --git a/src/components/fields/SelectField.tsx b/src/components/fields/SelectField.tsx
new file mode 100644
index 0000000..211da36
--- /dev/null
+++ b/src/components/fields/SelectField.tsx
@@ -0,0 +1,378 @@
+/**
+ * SelectField - Dropdown select with search
+ * Styled to match MultiSelectField for consistency
+ */
+
+import { useState, useRef, useEffect, type JSX, type KeyboardEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for SelectField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * SelectField component for dropdown selection with search
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function SelectField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t } = useI18n();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+ const listboxId = `${fieldId}-listbox`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // UI state
+ const [isOpen, setIsOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [focusedIndex, setFocusedIndex] = useState(-1);
+
+ const containerRef = useRef(null);
+ const searchInputRef = useRef(null);
+ const listboxRef = useRef(null);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Options
+ const options = config.options ?? [];
+ const normalizedValue = typeof value === 'string' || typeof value === 'number' ? value : null;
+ const filteredOptions = searchQuery
+ ? options.filter((opt) => opt.label.toLowerCase().includes(searchQuery.toLowerCase()))
+ : options;
+
+ // Get selected option for display
+ const selectedOption =
+ normalizedValue === null ? undefined : options.find((opt) => opt.value === normalizedValue);
+
+ // Handle option selection
+ const selectOption = (optionValue: string | number) => {
+ if (isDisabled) return;
+ setValue(config.key, optionValue);
+ setTouched(config.key, true);
+ setIsOpen(false);
+ setSearchQuery('');
+ setFocusedIndex(-1);
+ };
+
+ // Handle clear
+ const clearSelection = () => {
+ if (isDisabled) return;
+ setValue(config.key, '');
+ setTouched(config.key, true);
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (isDisabled) return;
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ if (!isOpen) {
+ setIsOpen(true);
+ setFocusedIndex(0);
+ } else {
+ setFocusedIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1));
+ }
+ break;
+
+ case 'ArrowUp':
+ e.preventDefault();
+ if (isOpen) {
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
+ }
+ break;
+
+ case 'Enter':
+ case ' ':
+ e.preventDefault();
+ if (isOpen && focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
+ selectOption(filteredOptions[focusedIndex].value);
+ } else if (!isOpen) {
+ setIsOpen(true);
+ setFocusedIndex(0);
+ }
+ break;
+
+ case 'Escape':
+ e.preventDefault();
+ setIsOpen(false);
+ setSearchQuery('');
+ setFocusedIndex(-1);
+ break;
+
+ case 'Home':
+ e.preventDefault();
+ if (isOpen) {
+ setFocusedIndex(0);
+ }
+ break;
+
+ case 'End':
+ e.preventDefault();
+ if (isOpen) {
+ setFocusedIndex(filteredOptions.length - 1);
+ }
+ break;
+ }
+ };
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setIsOpen(false);
+ setSearchQuery('');
+ setFocusedIndex(-1);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // Scroll focused option into view
+ useEffect(() => {
+ if (isOpen && focusedIndex >= 0 && listboxRef.current) {
+ const focusedElement = listboxRef.current.children[focusedIndex] as HTMLElement;
+ if (focusedElement?.scrollIntoView) {
+ focusedElement.scrollIntoView({ block: 'nearest' });
+ }
+ }
+ }, [focusedIndex, isOpen]);
+
+ // Focus search input when dropdown opens
+ useEffect(() => {
+ if (isOpen && searchInputRef.current) {
+ searchInputRef.current.focus();
+ }
+ }, [isOpen]);
+
+ return (
+
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+
+ {/* Main control area */}
+
= 0 ? `${fieldId}-option-${focusedIndex}` : undefined
+ }
+ tabIndex={isDisabled ? -1 : 0}
+ onKeyDown={handleKeyDown}
+ onClick={() => !isDisabled && setIsOpen(!isOpen)}
+ onBlur={(e) => {
+ if (!containerRef.current?.contains(e.relatedTarget as Node)) {
+ setIsOpen(false);
+ setSearchQuery('');
+ setTouched(config.key, true);
+ }
+ }}
+ className={`
+ formkit-select-control
+ min-h-[42px] px-3 py-2
+ border rounded-lg
+ cursor-pointer
+ transition-colors duration-150
+ focus:outline-none focus:ring-2 ${showError ? 'focus:ring-red-500' : 'focus:ring-blue-500'}
+ ${showError ? 'border-red-500 hover:border-red-400' : 'border-gray-300 hover:border-gray-400'}
+ ${isDisabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}
+ ${isOpen ? (showError ? 'ring-2 ring-red-500 border-red-500' : 'ring-2 ring-blue-500 border-blue-500') : ''}
+ `}
+ >
+
+ {/* Selected value display */}
+
+ {selectedOption
+ ? selectedOption.label
+ : (config.placeholder ?? t('field.selectOption'))}
+
+
+ {/* Clear button */}
+ {selectedOption && !isDisabled && (
+
+ )}
+
+ {/* Dropdown arrow */}
+
+
+
+
+ {/* Dropdown */}
+ {isOpen && !isDisabled && (
+
+ {/* Search input */}
+
+ {
+ setSearchQuery(e.target.value);
+ setFocusedIndex(0);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ setIsOpen(false);
+ setSearchQuery('');
+ setFocusedIndex(-1);
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setFocusedIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setFocusedIndex((prev) => Math.max(prev - 1, 0));
+ } else if (
+ e.key === 'Enter' &&
+ focusedIndex >= 0 &&
+ focusedIndex < filteredOptions.length
+ ) {
+ e.preventDefault();
+ selectOption(filteredOptions[focusedIndex].value);
+ }
+ }}
+ placeholder={t('field.search')}
+ aria-label={t('field.search')}
+ className="
+ w-full px-3 py-1.5
+ text-sm
+ border border-gray-300 rounded-md
+ focus:outline-none
+ "
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+ {/* Options list */}
+
+ {filteredOptions.length === 0 ? (
+ -
+ {t('field.noOptionsFound')}
+
+ ) : (
+ filteredOptions.map((option, index) => {
+ const isSelected = normalizedValue !== null && option.value === normalizedValue;
+ const isFocused = focusedIndex === index;
+
+ return (
+ - {
+ e.stopPropagation();
+ if (!option.disabled) {
+ selectOption(option.value);
+ }
+ }}
+ onMouseEnter={() => setFocusedIndex(index)}
+ className={`
+ flex items-center gap-2 px-3 py-2
+ cursor-pointer text-sm
+ transition-colors duration-100
+ ${isFocused ? 'bg-blue-50' : ''}
+ ${isSelected ? 'bg-blue-100' : ''}
+ ${option.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-50'}
+ `}
+ >
+ {option.label}
+
+ );
+ })
+ )}
+
+
+ )}
+
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as SelectFieldProps };
diff --git a/src/components/fields/SliderField.tsx b/src/components/fields/SliderField.tsx
new file mode 100644
index 0000000..0d39ba0
--- /dev/null
+++ b/src/components/fields/SliderField.tsx
@@ -0,0 +1,163 @@
+/**
+ * SliderField - Range slider input
+ */
+
+import type { JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for SliderField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * SliderField component for numeric range input
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function SliderField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+ const valueId = `${fieldId}-value`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Slider config with defaults
+ const min = config.min ?? 0;
+ const max = config.max ?? 100;
+ const step = config.step ?? 1;
+ const showValue = config.showValue !== false; // Default true
+
+ // Current numeric value
+ const numericValue = typeof value === 'number' ? value : min;
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Calculate percentage for styling the track fill
+ const percentage = ((numericValue - min) / (max - min)) * 100;
+
+ // Handle number input change with clamping
+ const handleInputChange = (inputValue: string) => {
+ const parsed = Number(inputValue);
+ if (Number.isNaN(parsed)) return;
+ // Clamp value to min/max range
+ const clamped = Math.min(Math.max(parsed, min), max);
+ setValue(config.key, clamped);
+ };
+
+ return (
+
+
+
+ {showValue && (
+ handleInputChange(e.target.value)}
+ onBlur={() => setTouched(config.key, true)}
+ className={`
+ formkit-slider-value
+ w-16 text-center text-sm font-medium
+ text-blue-600 bg-blue-50
+ px-2 py-0.5 rounded
+ border border-blue-200
+ focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
+ [appearance:textfield]
+ [&::-webkit-outer-spin-button]:appearance-none
+ [&::-webkit-inner-spin-button]:appearance-none
+ ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
+ `}
+ />
+ )}
+
+
+
+ setValue(config.key, Number(e.target.value))}
+ onBlur={() => setTouched(config.key, true)}
+ style={{
+ background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${percentage}%, #e5e7eb ${percentage}%, #e5e7eb 100%)`,
+ }}
+ className={`
+ formkit-slider
+ w-full h-3 rounded-lg appearance-none cursor-pointer
+ [&::-webkit-slider-thumb]:appearance-none
+ [&::-webkit-slider-thumb]:h-5
+ [&::-webkit-slider-thumb]:w-5
+ [&::-webkit-slider-thumb]:rounded-full
+ [&::-webkit-slider-thumb]:bg-white
+ [&::-webkit-slider-thumb]:border-2
+ [&::-webkit-slider-thumb]:border-blue-600
+ [&::-webkit-slider-thumb]:shadow-md
+ [&::-webkit-slider-thumb]:cursor-pointer
+ [&::-webkit-slider-thumb]:transition-all
+ [&::-webkit-slider-thumb]:duration-150
+ [&::-webkit-slider-thumb]:hover:scale-110
+ [&::-moz-range-thumb]:h-5
+ [&::-moz-range-thumb]:w-5
+ [&::-moz-range-thumb]:rounded-full
+ [&::-moz-range-thumb]:bg-white
+ [&::-moz-range-thumb]:border-2
+ [&::-moz-range-thumb]:border-blue-600
+ [&::-moz-range-thumb]:shadow-md
+ [&::-moz-range-thumb]:cursor-pointer
+ ${showError ? '[&::-webkit-slider-thumb]:border-red-500 [&::-moz-range-thumb]:border-red-500' : ''}
+ ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
+ `}
+ />
+
+
+ {/* Min/Max labels */}
+
+ {min}
+ {max}
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as SliderFieldProps };
diff --git a/src/components/fields/SwitchField.tsx b/src/components/fields/SwitchField.tsx
new file mode 100644
index 0000000..c8ac5cb
--- /dev/null
+++ b/src/components/fields/SwitchField.tsx
@@ -0,0 +1,104 @@
+/**
+ * SwitchField - Toggle switch input
+ */
+
+import type { JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for SwitchField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * SwitchField component for boolean toggle
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function SwitchField({ config }: Props): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+
+ const fieldId = `field-${config.key}`;
+ const labelId = `${fieldId}-label`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+ const isChecked = Boolean(value);
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ return (
+
+
+
+ !isDisabled && setValue(config.key, !isChecked)}
+ >
+ {config.label}
+ {config.required && *}
+
+
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as SwitchFieldProps };
diff --git a/src/components/fields/TagsField.tsx b/src/components/fields/TagsField.tsx
new file mode 100644
index 0000000..ef0073a
--- /dev/null
+++ b/src/components/fields/TagsField.tsx
@@ -0,0 +1,234 @@
+/**
+ * TagsField - Multi-tag input with add/remove functionality
+ */
+
+import { useState, useRef, type JSX, type KeyboardEvent, type ClipboardEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for TagsField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * TagsField component for entering multiple tags/keywords
+ * Supports Enter/comma to add, backspace to remove, paste for bulk add
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function TagsField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t } = useI18n();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+ const inputId = `${fieldId}-input`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Parse current value - expect string[] or fall back to empty array
+ const tags: string[] = Array.isArray(value)
+ ? (value.filter((v): v is string => typeof v === 'string') as string[])
+ : [];
+
+ // Input state for new tag
+ const [inputValue, setInputValue] = useState('');
+ const inputRef = useRef(null);
+
+ // Config options with defaults
+ const maxTags = config.maxTags ?? Infinity;
+ const minTags = config.minTags ?? 0;
+ const allowDuplicates = config.allowDuplicates ?? true;
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Add a tag
+ const addTag = (tag: string) => {
+ const trimmed = tag.trim();
+ if (!trimmed) return;
+ if (tags.length >= maxTags) return;
+ if (!allowDuplicates && tags.includes(trimmed)) return;
+
+ setValue(config.key, [...tags, trimmed]);
+ setInputValue('');
+ };
+
+ // Remove a tag by index (respects minTags)
+ const removeTag = (index: number) => {
+ if (tags.length <= minTags) return; // Prevent removing below minimum
+ const newTags = tags.filter((_, i) => i !== index);
+ setValue(config.key, newTags);
+ };
+
+ // Handle keyboard input
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault();
+ addTag(inputValue);
+ } else if (e.key === 'Backspace' && !inputValue && tags.length > minTags) {
+ // Remove last tag when backspace on empty input (respects minTags)
+ removeTag(tags.length - 1);
+ }
+ };
+
+ // Handle paste - split by commas and add all
+ const handlePaste = (e: ClipboardEvent) => {
+ const pastedText = e.clipboardData.getData('text');
+ if (pastedText.includes(',')) {
+ e.preventDefault();
+ const newTags = pastedText
+ .split(',')
+ .map((t) => t.trim())
+ .filter((t) => t !== '');
+
+ const currentTags = [...tags];
+ for (const tag of newTags) {
+ if (currentTags.length >= maxTags) break;
+ if (!allowDuplicates && currentTags.includes(tag)) continue;
+ currentTags.push(tag);
+ }
+ setValue(config.key, currentTags);
+ }
+ };
+
+ // Focus input when clicking container
+ const handleContainerClick = () => {
+ inputRef.current?.focus();
+ };
+
+ const canAddMore = tags.length < maxTags;
+
+ return (
+
+
+
+
+ {tags.map((tag, index) => (
+
+ {tag}
+ {!isDisabled && tags.length > minTags && (
+
+ )}
+
+ ))}
+
+ {canAddMore && !isDisabled && (
+
setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onPaste={handlePaste}
+ onBlur={() => {
+ // Add tag on blur if there's input
+ if (inputValue.trim()) {
+ addTag(inputValue);
+ }
+ setTouched(config.key, true);
+ }}
+ placeholder={tags.length === 0 ? (config.placeholder ?? t('field.typeAndEnter')) : ''}
+ disabled={isDisabled}
+ aria-invalid={showError}
+ className="
+ formkit-tags-input
+ flex-1 min-w-[120px]
+ border-none outline-none
+ bg-transparent
+ text-sm
+ placeholder:text-gray-400
+ "
+ />
+ )}
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+ {(maxTags < Infinity || minTags > 0) && (
+
+ {tags.length}
+ {maxTags < Infinity && `/${maxTags}`} tags
+ {minTags > 0 && ` (min: ${minTags})`}
+
+ )}
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as TagsFieldProps };
diff --git a/src/components/fields/TextField.tsx b/src/components/fields/TextField.tsx
new file mode 100644
index 0000000..fd4ec25
--- /dev/null
+++ b/src/components/fields/TextField.tsx
@@ -0,0 +1,102 @@
+/**
+ * TextField - Handles text, email, password, and number inputs
+ */
+
+import type { JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { FieldType } from '../../core/types';
+import { useFormKitContext } from '../context/FormKitContext';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for TextField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * TextField component for text, email, password, and number inputs
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function TextField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Map FieldType to input type
+ const inputType = (): string => {
+ switch (config.type) {
+ case FieldType.EMAIL:
+ return 'email';
+ case FieldType.PASSWORD:
+ return 'password';
+ case FieldType.NUMBER:
+ return 'number';
+ default:
+ return 'text';
+ }
+ };
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ const inputValue = typeof value === 'string' || typeof value === 'number' ? String(value) : '';
+
+ return (
+
+
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
{
+ const newValue =
+ config.type === FieldType.NUMBER ? Number(e.target.value) : e.target.value;
+ setValue(config.key, newValue);
+ }}
+ onBlur={() => setTouched(config.key, true)}
+ className={`
+ formkit-input
+ w-full px-3 py-2
+ border rounded-md
+ focus:outline-none focus:ring-2 ${showError ? 'focus:ring-red-500' : 'focus:ring-blue-500'}
+ ${showError ? 'border-red-500' : 'border-gray-300'}
+ ${isDisabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}
+ `}
+ />
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as TextFieldProps };
diff --git a/src/components/fields/TextareaField.tsx b/src/components/fields/TextareaField.tsx
new file mode 100644
index 0000000..ebb4b9b
--- /dev/null
+++ b/src/components/fields/TextareaField.tsx
@@ -0,0 +1,82 @@
+/**
+ * TextareaField - Multiline text input
+ */
+
+import type { JSX } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for TextareaField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * TextareaField component for multiline text input
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function TextareaField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+
+ const fieldId = `field-${config.key}`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ return (
+
+
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
+ );
+}
+
+export type { Props as TextareaFieldProps };
diff --git a/src/components/fields/TimeField.tsx b/src/components/fields/TimeField.tsx
new file mode 100644
index 0000000..20331fb
--- /dev/null
+++ b/src/components/fields/TimeField.tsx
@@ -0,0 +1,440 @@
+/**
+ * TimeField - Custom time picker dropdown
+ * Styled to match SelectField/MultiSelectField for consistency
+ */
+
+import { useState, useRef, useEffect, type JSX, type KeyboardEvent } from 'react';
+import type { FieldConfig } from '../../models/FieldConfig';
+import { useFormKitContext } from '../context/FormKitContext';
+import { useI18n } from '../../hooks/useI18n';
+import FieldLabel from '../layout/FieldLabel';
+import FieldError from '../layout/FieldError';
+
+/**
+ * Props for TimeField
+ */
+type Props = {
+ config: FieldConfig;
+};
+
+/**
+ * TimeField component for time selection
+ * Custom dropdown matching SelectField styling
+ * Follows WCAG 2.1 AA accessibility requirements
+ */
+export default function TimeField({ config }: Readonly): JSX.Element {
+ const { getValue, setValue, getError, getTouched, setTouched, getValues } = useFormKitContext();
+ const { t, translations } = useI18n();
+
+ const fieldId = `field-${config.key}`;
+ const dialogId = `${fieldId}-dialog`;
+ const errorId = `${fieldId}-error`;
+ const descId = `${fieldId}-desc`;
+
+ const value = getValue(config.key);
+ const error = getError(config.key);
+ const touched = getTouched(config.key);
+ const showError = touched && !!error;
+
+ // Parse current value (HH:mm format)
+ const currentValue = typeof value === 'string' ? value : '';
+ const [hours, minutes] = currentValue.split(':').map((v) => Number.parseInt(v, 10) || 0);
+
+ // UI state
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedHour, setSelectedHour] = useState(hours || 0);
+ const [selectedMinute, setSelectedMinute] = useState(minutes || 0);
+ const [focusedColumn, setFocusedColumn] = useState<'hour' | 'minute'>('hour');
+
+ const containerRef = useRef(null);
+ const hourListRef = useRef(null);
+ const minuteListRef = useRef(null);
+ const mouseDownInsideRef = useRef(false);
+
+ // Config options
+ const timeStep = config.timeStep ?? 60; // in seconds, convert to minutes
+ const minuteInterval = Math.max(1, Math.floor(timeStep / 60));
+
+ // Compute disabled state
+ const isDisabled =
+ typeof config.disabled === 'function' ? config.disabled(getValues()) : config.disabled;
+
+ // Build aria-describedby
+ const describedBy =
+ [showError ? errorId : null, config.description ? descId : null].filter(Boolean).join(' ') ||
+ undefined;
+
+ // Generate hour options (0-23)
+ const hourOptions = Array.from({ length: 24 }, (_, i) => i);
+
+ // Generate minute options based on interval
+ const minuteOptions = Array.from(
+ { length: Math.ceil(60 / minuteInterval) },
+ (_, i) => i * minuteInterval,
+ ).filter((m) => m < 60);
+
+ // Format time for display
+ const formatDisplayTime = (): string => {
+ if (!currentValue) return config.placeholder ?? t('datetime.selectTime');
+ const h = hours % 12 || 12;
+ const ampm = hours >= 12 ? translations.datetime.pm : translations.datetime.am;
+ return `${h}:${String(minutes).padStart(2, '0')} ${ampm}`;
+ };
+
+ // Format time for value (HH:mm)
+ const formatValueTime = (h: number, m: number): string => {
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
+ };
+
+ // Handle time selection
+ const selectTime = (h: number, m: number) => {
+ if (isDisabled || config.readOnly) return;
+ setValue(config.key, formatValueTime(h, m));
+ setTouched(config.key, true);
+ };
+
+ // Handle clear
+ const clearSelection = () => {
+ if (isDisabled || config.readOnly) return;
+ setValue(config.key, '');
+ setTouched(config.key, true);
+ };
+
+ // Handle confirm
+ const confirmSelection = () => {
+ selectTime(selectedHour, selectedMinute);
+ setIsOpen(false);
+ };
+
+ // Helper to open dropdown with state reset
+ const openDropdown = () => {
+ if (isDisabled || config.readOnly) return;
+ setSelectedHour(hours || 0);
+ setSelectedMinute(minutes || 0);
+ setFocusedColumn('hour');
+ setIsOpen(true);
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (isDisabled) return;
+
+ switch (e.key) {
+ case 'Enter':
+ case ' ':
+ e.preventDefault();
+ if (!isOpen) {
+ openDropdown();
+ } else {
+ confirmSelection();
+ }
+ break;
+
+ case 'Escape':
+ e.preventDefault();
+ setIsOpen(false);
+ break;
+
+ case 'ArrowUp':
+ if (isOpen) {
+ e.preventDefault();
+ if (focusedColumn === 'hour') {
+ setSelectedHour((prev) => (prev - 1 + 24) % 24);
+ } else {
+ const idx = minuteOptions.indexOf(selectedMinute);
+ const newIdx = (idx - 1 + minuteOptions.length) % minuteOptions.length;
+ setSelectedMinute(minuteOptions[newIdx]);
+ }
+ }
+ break;
+
+ case 'ArrowDown':
+ e.preventDefault();
+ if (!isOpen) {
+ openDropdown();
+ } else {
+ if (focusedColumn === 'hour') {
+ setSelectedHour((prev) => (prev + 1) % 24);
+ } else {
+ const idx = minuteOptions.indexOf(selectedMinute);
+ const newIdx = (idx + 1) % minuteOptions.length;
+ setSelectedMinute(minuteOptions[newIdx]);
+ }
+ }
+ break;
+
+ case 'ArrowLeft':
+ if (isOpen) {
+ e.preventDefault();
+ setFocusedColumn('hour');
+ }
+ break;
+
+ case 'ArrowRight':
+ if (isOpen) {
+ e.preventDefault();
+ setFocusedColumn('minute');
+ }
+ break;
+
+ case 'Tab':
+ if (isOpen) {
+ e.preventDefault();
+ setFocusedColumn((prev) => (prev === 'hour' ? 'minute' : 'hour'));
+ }
+ break;
+ }
+ };
+
+ // Track mousedown inside container to prevent blur from closing dropdown
+ useEffect(() => {
+ const handleMouseDown = (e: MouseEvent) => {
+ mouseDownInsideRef.current = containerRef.current?.contains(e.target as Node) ?? false;
+ };
+
+ const handleMouseUp = (e: MouseEvent) => {
+ // Close dropdown only if both mousedown and mouseup are outside
+ if (!mouseDownInsideRef.current && !containerRef.current?.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ mouseDownInsideRef.current = false;
+ };
+
+ document.addEventListener('mousedown', handleMouseDown);
+ document.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ document.removeEventListener('mousedown', handleMouseDown);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, []);
+
+ // Scroll selected items into view
+ useEffect(() => {
+ if (isOpen && hourListRef.current) {
+ const selectedEl = hourListRef.current.querySelector('[aria-selected="true"]');
+ if (selectedEl?.scrollIntoView) {
+ selectedEl.scrollIntoView({ block: 'center' });
+ }
+ }
+ }, [isOpen, selectedHour]);
+
+ useEffect(() => {
+ if (isOpen && minuteListRef.current) {
+ const selectedEl = minuteListRef.current.querySelector('[aria-selected="true"]');
+ if (selectedEl?.scrollIntoView) {
+ selectedEl.scrollIntoView({ block: 'center' });
+ }
+ }
+ }, [isOpen, selectedMinute]);
+
+ return (
+
+
+
+ {config.description && !showError && (
+
+ {config.description}
+
+ )}
+
+
+ {/* Main control area */}
+
+
+ {currentValue && !isDisabled && !config.readOnly && (
+
+ )}
+
+ {/* Time picker dropdown */}
+ {isOpen && !isDisabled && !config.readOnly && (
+
+ )}
+
+
+ {showError &&
}
+
+ );
+}
+
+export type { Props as TimeFieldProps };
diff --git a/src/components/fields/__tests__/DateField.test.tsx b/src/components/fields/__tests__/DateField.test.tsx
new file mode 100644
index 0000000..bb9508c
--- /dev/null
+++ b/src/components/fields/__tests__/DateField.test.tsx
@@ -0,0 +1,129 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('DateField', () => {
+ const renderDateField = (fieldConfig: Partial = {}, defaultValue = '') => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'birthdate',
+ label: 'Birth Date',
+ type: FieldType.DATE,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('opens calendar and selects today', async () => {
+ const user = userEvent.setup();
+ renderDateField();
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('button', { name: 'Today' }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('supports month navigation controls', async () => {
+ const user = userEvent.setup();
+ renderDateField();
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('button', { name: 'Previous month' }));
+ await user.click(screen.getByRole('button', { name: 'Next month' }));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('supports keyboard open/navigate/select', async () => {
+ const user = userEvent.setup();
+ renderDateField();
+
+ const combobox = screen.getByRole('combobox');
+ combobox.focus();
+
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{ArrowRight}');
+ await user.keyboard('{Enter}');
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows clear button for existing value and clears it', async () => {
+ const user = userEvent.setup();
+ renderDateField({}, '2026-04-06');
+
+ const clearButton = screen.getByRole('button', { name: 'Clear selection' });
+ await user.click(clearButton);
+
+ expect(screen.queryByText(/Apr 6, 2026/)).not.toBeInTheDocument();
+ });
+
+ it('does not open when readOnly', async () => {
+ const user = userEvent.setup();
+ renderDateField({ readOnly: true });
+
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('supports additional keyboard navigation directions', async () => {
+ const user = userEvent.setup();
+ renderDateField();
+
+ const combobox = screen.getByRole('combobox');
+ combobox.focus();
+
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{ArrowLeft}');
+ await user.keyboard('{ArrowUp}');
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{Enter}');
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('closes calendar on escape key', async () => {
+ const user = userEvent.setup();
+ renderDateField();
+
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('toggles calendar open and closed on combobox click', async () => {
+ const user = userEvent.setup();
+ renderDateField();
+
+ const combobox = screen.getByRole('combobox');
+ await user.click(combobox);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.click(combobox);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/fields/__tests__/DateTimeField.test.tsx b/src/components/fields/__tests__/DateTimeField.test.tsx
new file mode 100644
index 0000000..2ebd300
--- /dev/null
+++ b/src/components/fields/__tests__/DateTimeField.test.tsx
@@ -0,0 +1,200 @@
+/**
+ * Tests for DateTimeField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('DateTimeField', () => {
+ const renderDateTimeField = (fieldConfig: Partial = {}, defaultValue = '') => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'appointment',
+ label: 'Appointment',
+ type: FieldType.DATETIME,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders datetime picker', () => {
+ renderDateTimeField();
+ const combobox = screen.getByRole('combobox');
+ expect(combobox).toBeInTheDocument();
+ expect(combobox).toHaveAttribute('aria-haspopup', 'dialog');
+ });
+
+ it('displays default value formatted', () => {
+ renderDateTimeField({}, '2026-03-15T14:30');
+ // Should display formatted as "Mar 15, 2026, 2:30 PM"
+ expect(screen.getByText(/Mar 15, 2026/)).toBeInTheDocument();
+ });
+
+ it('opens picker with date and time tabs', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Date/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Time/i })).toBeInTheDocument();
+ });
+
+ it('shows required indicator', () => {
+ renderDateTimeField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('shows description', () => {
+ renderDateTimeField({ description: 'Select date and time' });
+ expect(screen.getByText('Select date and time')).toBeInTheDocument();
+ });
+
+ it('disables interaction when disabled', () => {
+ renderDateTimeField({ disabled: true });
+ expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('has proper aria attributes', () => {
+ renderDateTimeField({ required: true });
+ const combobox = screen.getByRole('combobox');
+ expect(combobox).toHaveAttribute('aria-required', 'true');
+ });
+
+ it('closes dropdown on escape', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows Today button in calendar', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.getByRole('button', { name: 'Today' })).toBeInTheDocument();
+ });
+
+ it('lets user select date and time then confirm', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+
+ // Pick a date cell and switch to time tab
+ const dateCells = screen.getAllByRole('gridcell');
+ const clickableDate = dateCells
+ .map((cell) => cell.querySelector('button'))
+ .find((btn): btn is HTMLButtonElement => btn instanceof HTMLButtonElement);
+ expect(clickableDate).toBeDefined();
+ if (!clickableDate) return;
+ await user.click(clickableDate);
+
+ await user.click(screen.getByRole('button', { name: /Time/i }));
+ await user.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('supports month navigation controls', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('button', { name: 'Previous month' }));
+ await user.click(screen.getByRole('button', { name: 'Next month' }));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('shows clear button for existing value and clears it', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField({}, '2026-03-15T14:30');
+
+ const clearButton = screen.getByRole('button', { name: 'Clear selection' });
+ await user.click(clearButton);
+
+ expect(screen.queryByText(/Mar 15, 2026/)).not.toBeInTheDocument();
+ });
+
+ it('keeps confirm disabled when no date is selected', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('button', { name: /Time/i }));
+
+ expect(screen.getByRole('button', { name: /Confirm/i })).toBeDisabled();
+ });
+
+ it('supports keyboard open with Enter and closes with Escape', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ const combobox = screen.getByRole('combobox');
+ combobox.focus();
+
+ await user.keyboard('{Enter}');
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('moves from date tab to time tab via Today button', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('button', { name: 'Today' }));
+
+ expect(screen.getByText('Hour')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Confirm/i })).not.toBeDisabled();
+ });
+
+ it('does not open dropdown when readOnly', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField({ readOnly: true });
+
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('toggles dropdown open and closed on combobox click', async () => {
+ const user = userEvent.setup();
+ renderDateTimeField();
+
+ const combobox = screen.getByRole('combobox');
+ await user.click(combobox);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.click(combobox);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/fields/__tests__/Field.test.tsx b/src/components/fields/__tests__/Field.test.tsx
new file mode 100644
index 0000000..587bbdf
--- /dev/null
+++ b/src/components/fields/__tests__/Field.test.tsx
@@ -0,0 +1,451 @@
+/**
+ * Tests for Field component - universal field router
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+// Test Field routing through DynamicForm since Field uses FormKitContext
+describe('Field routing', () => {
+ describe('text input types', () => {
+ it('routes TEXT to TextField with type="text"', () => {
+ const fields: FieldConfig[] = [{ key: 'name', label: 'Name', type: FieldType.TEXT }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
+ });
+
+ it('routes EMAIL to TextField with type="email"', () => {
+ const fields: FieldConfig[] = [{ key: 'email', label: 'Email', type: FieldType.EMAIL }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
+ });
+
+ it('routes PASSWORD to TextField with type="password"', () => {
+ const fields: FieldConfig[] = [
+ { key: 'password', label: 'Password', type: FieldType.PASSWORD },
+ ];
+
+ render(
+ ,
+ );
+
+ // Password fields don't have textbox role
+ expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
+ });
+
+ it('routes NUMBER to TextField with type="number"', () => {
+ const fields: FieldConfig[] = [{ key: 'age', label: 'Age', type: FieldType.NUMBER }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('spinbutton')).toHaveAttribute('type', 'number');
+ });
+ });
+
+ describe('select and option types', () => {
+ it('routes SELECT to SelectField', async () => {
+ const user = userEvent.setup();
+ const fields: FieldConfig[] = [
+ {
+ key: 'country',
+ label: 'Country',
+ type: FieldType.SELECT,
+ options: [
+ { value: 'us', label: 'United States' },
+ { value: 'uk', label: 'United Kingdom' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const select = screen.getByRole('combobox');
+ expect(select).toBeInTheDocument();
+
+ // Click to open dropdown and verify options
+ await user.click(select);
+ expect(screen.getByRole('option', { name: 'United States' })).toBeInTheDocument();
+ });
+
+ it('routes RADIO to RadioGroupField', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'size',
+ label: 'Size',
+ type: FieldType.RADIO,
+ options: [
+ { value: 'small', label: 'Small' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'large', label: 'Large' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('radiogroup')).toBeInTheDocument();
+ expect(screen.getAllByRole('radio')).toHaveLength(3);
+ });
+ });
+
+ describe('boolean types', () => {
+ it('routes CHECKBOX to CheckboxField', () => {
+ const fields: FieldConfig[] = [{ key: 'agree', label: 'I agree', type: FieldType.CHECKBOX }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('checkbox')).toBeInTheDocument();
+ });
+
+ it('routes SWITCH to SwitchField', () => {
+ const fields: FieldConfig[] = [
+ { key: 'notifications', label: 'Enable notifications', type: FieldType.SWITCH },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('switch')).toBeInTheDocument();
+ });
+ });
+
+ describe('multiline input', () => {
+ it('routes TEXTAREA to TextareaField', () => {
+ const fields: FieldConfig[] = [
+ { key: 'description', label: 'Description', type: FieldType.TEXTAREA },
+ ];
+
+ render(
+ ,
+ );
+
+ const textarea = screen.getByRole('textbox');
+ expect(textarea.tagName).toBe('TEXTAREA');
+ });
+ });
+
+ describe('date input', () => {
+ it('routes DATE to DateField', () => {
+ const fields: FieldConfig[] = [{ key: 'birthday', label: 'Birthday', type: FieldType.DATE }];
+
+ render(
+ ,
+ );
+
+ // DateField now uses custom dropdown with combobox role
+ const combobox = screen.getByRole('combobox');
+ expect(combobox).toBeInTheDocument();
+ expect(combobox).toHaveAttribute('aria-haspopup', 'dialog');
+ });
+ });
+
+ describe('file input', () => {
+ it('routes FILE to FileField', () => {
+ const fields: FieldConfig[] = [{ key: 'avatar', label: 'Avatar', type: FieldType.FILE }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Avatar')).toHaveAttribute('type', 'file');
+ });
+ });
+});
+
+describe('Field props', () => {
+ it('renders placeholder when provided', () => {
+ const fields: FieldConfig[] = [
+ { key: 'name', label: 'Name', type: FieldType.TEXT, placeholder: 'Enter your name' },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByPlaceholderText('Enter your name')).toBeInTheDocument();
+ });
+
+ it('renders description when provided', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'username',
+ label: 'Username',
+ type: FieldType.TEXT,
+ description: 'Choose a unique username',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Choose a unique username')).toBeInTheDocument();
+ });
+
+ it('disables field when disabled is true', () => {
+ const fields: FieldConfig[] = [
+ { key: 'locked', label: 'Locked Field', type: FieldType.TEXT, disabled: true },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Locked Field')).toBeDisabled();
+ });
+
+ it('makes field read-only when readOnly is true', () => {
+ const fields: FieldConfig[] = [
+ { key: 'readonly', label: 'Read Only Field', type: FieldType.TEXT, readOnly: true },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Read Only Field')).toHaveAttribute('readonly');
+ });
+});
+
+describe('Field interactions', () => {
+ it('handles text input changes', async () => {
+ const user = userEvent.setup();
+ const fields: FieldConfig[] = [{ key: 'name', label: 'Name', type: FieldType.TEXT }];
+
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText('Name');
+ await user.type(input, 'Test Value');
+
+ expect(input).toHaveValue('Test Value');
+ });
+
+ it('handles checkbox toggle', async () => {
+ const user = userEvent.setup();
+ const fields: FieldConfig[] = [{ key: 'agree', label: 'I agree', type: FieldType.CHECKBOX }];
+
+ render(
+ ,
+ );
+
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).not.toBeChecked();
+
+ await user.click(checkbox);
+ expect(checkbox).toBeChecked();
+
+ await user.click(checkbox);
+ expect(checkbox).not.toBeChecked();
+ });
+
+ it('handles select change', async () => {
+ const user = userEvent.setup();
+ const fields: FieldConfig[] = [
+ {
+ key: 'color',
+ label: 'Color',
+ type: FieldType.SELECT,
+ options: [
+ { value: 'red', label: 'Red' },
+ { value: 'blue', label: 'Blue' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const select = screen.getByRole('combobox');
+ await user.click(select);
+ await user.click(screen.getByRole('option', { name: 'Blue' }));
+
+ // After selection, the selected option text should be visible
+ expect(screen.getByText('Blue')).toBeInTheDocument();
+ });
+
+ it('handles radio selection', async () => {
+ const user = userEvent.setup();
+ const fields: FieldConfig[] = [
+ {
+ key: 'plan',
+ label: 'Plan',
+ type: FieldType.RADIO,
+ options: [
+ { value: 'free', label: 'Free' },
+ { value: 'pro', label: 'Pro' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const proRadio = screen.getByLabelText('Pro');
+ await user.click(proRadio);
+
+ expect(proRadio).toBeChecked();
+ expect(screen.getByLabelText('Free')).not.toBeChecked();
+ });
+
+ it('handles switch toggle', async () => {
+ const user = userEvent.setup();
+ const fields: FieldConfig[] = [
+ { key: 'enabled', label: 'Enable feature', type: FieldType.SWITCH },
+ ];
+
+ render(
+ ,
+ );
+
+ const switchEl = screen.getByRole('switch');
+ expect(switchEl).toHaveAttribute('aria-checked', 'false');
+
+ await user.click(switchEl);
+ expect(switchEl).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('handles textarea input', async () => {
+ const user = userEvent.setup();
+ const fields: FieldConfig[] = [{ key: 'bio', label: 'Bio', type: FieldType.TEXTAREA }];
+
+ render(
+ ,
+ );
+
+ const textarea = screen.getByLabelText('Bio');
+ await user.type(textarea, 'Hello\nWorld');
+
+ expect(textarea).toHaveValue('Hello\nWorld');
+ });
+});
diff --git a/src/components/fields/__tests__/FieldEdgeCases.test.tsx b/src/components/fields/__tests__/FieldEdgeCases.test.tsx
new file mode 100644
index 0000000..7a4b31e
--- /dev/null
+++ b/src/components/fields/__tests__/FieldEdgeCases.test.tsx
@@ -0,0 +1,576 @@
+/**
+ * Comprehensive tests for individual field components
+ * Tests edge cases, disabled state, descriptions, and error display
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import DynamicForm from '../../form/DynamicForm';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+
+describe('TextField edge cases', () => {
+ it('renders with description', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'username',
+ label: 'Username',
+ type: FieldType.TEXT,
+ description: 'Choose a unique username',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Choose a unique username')).toBeInTheDocument();
+ });
+
+ it('handles disabled function', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'lockField',
+ label: 'Lock Field',
+ type: FieldType.CHECKBOX,
+ },
+ {
+ key: 'conditionalField',
+ label: 'Conditional Field',
+ type: FieldType.TEXT,
+ disabled: (values) => values.lockField === true,
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ // Initially not disabled
+ expect(screen.getByLabelText('Conditional Field')).not.toBeDisabled();
+ });
+
+ it('handles different input types correctly', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [{ key: 'numField', label: 'Number', type: FieldType.NUMBER }];
+
+ render(
+ ,
+ );
+
+ const input = screen.getByRole('spinbutton');
+ await user.clear(input);
+ await user.type(input, '42');
+
+ expect(input).toHaveValue(42);
+ });
+});
+
+describe('TextareaField edge cases', () => {
+ it('renders with description', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'bio',
+ label: 'Bio',
+ type: FieldType.TEXTAREA,
+ description: 'Tell us about yourself',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Tell us about yourself')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ const fields: FieldConfig[] = [
+ { key: 'bio', label: 'Bio', type: FieldType.TEXTAREA, disabled: true },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('textbox')).toBeDisabled();
+ });
+
+ it('displays validation error with role="alert"', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [{ key: 'bio', label: 'Bio', type: FieldType.TEXTAREA }];
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Bio too short');
+ });
+ });
+});
+
+describe('SelectField edge cases', () => {
+ it('renders with description', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'country',
+ label: 'Country',
+ type: FieldType.SELECT,
+ description: 'Select your country',
+ options: [{ value: 'us', label: 'United States' }],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Select your country')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'country',
+ label: 'Country',
+ type: FieldType.SELECT,
+ disabled: true,
+ options: [{ value: 'us', label: 'United States' }],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ // Custom dropdown uses tabIndex=-1 when disabled
+ expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('displays validation error', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [
+ {
+ key: 'country',
+ label: 'Country',
+ type: FieldType.SELECT,
+ options: [
+ { value: '', label: 'Select...' },
+ { value: 'us', label: 'United States' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Please select a country');
+ });
+ });
+});
+
+describe('CheckboxField edge cases', () => {
+ it('renders with description', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'agree',
+ label: 'I agree',
+ type: FieldType.CHECKBOX,
+ description: 'You must agree to continue',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('You must agree to continue')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ const fields: FieldConfig[] = [
+ { key: 'agree', label: 'I agree', type: FieldType.CHECKBOX, disabled: true },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('checkbox')).toBeDisabled();
+ });
+
+ it('displays validation error', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [{ key: 'agree', label: 'I agree', type: FieldType.CHECKBOX }];
+
+ render(
+ v === true, { message: 'You must agree' }),
+ })}
+ fields={fields}
+ defaultValues={{ agree: false }}
+ onSubmit={vi.fn()}
+ />,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ });
+ });
+});
+
+describe('RadioGroupField edge cases', () => {
+ it('renders with description', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'plan',
+ label: 'Plan',
+ type: FieldType.RADIO,
+ description: 'Choose your subscription plan',
+ options: [
+ { value: 'free', label: 'Free' },
+ { value: 'pro', label: 'Pro' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Choose your subscription plan')).toBeInTheDocument();
+ });
+
+ it('handles disabled state on individual options', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'plan',
+ label: 'Plan',
+ type: FieldType.RADIO,
+ disabled: true,
+ options: [
+ { value: 'free', label: 'Free' },
+ { value: 'pro', label: 'Pro' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Free')).toBeDisabled();
+ expect(screen.getByLabelText('Pro')).toBeDisabled();
+ });
+
+ it('displays validation error', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [
+ {
+ key: 'plan',
+ label: 'Plan',
+ type: FieldType.RADIO,
+ options: [
+ { value: 'free', label: 'Free' },
+ { value: 'pro', label: 'Pro' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Please select a plan');
+ });
+ });
+});
+
+describe('SwitchField edge cases', () => {
+ it('renders with description', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'notifications',
+ label: 'Enable notifications',
+ type: FieldType.SWITCH,
+ description: 'Receive email notifications',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Receive email notifications')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'notifications',
+ label: 'Enable notifications',
+ type: FieldType.SWITCH,
+ disabled: true,
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ // Switch uses button with disabled attribute
+ expect(screen.getByRole('switch')).toBeDisabled();
+ });
+
+ it('displays validation error', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [
+ { key: 'mustEnable', label: 'Must Enable', type: FieldType.SWITCH },
+ ];
+
+ render(
+ v === true, { message: 'This must be enabled' }),
+ })}
+ fields={fields}
+ defaultValues={{ mustEnable: false }}
+ onSubmit={vi.fn()}
+ />,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ });
+ });
+});
+
+describe('DateField edge cases', () => {
+ it('renders with description', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'birthdate',
+ label: 'Birth Date',
+ type: FieldType.DATE,
+ description: 'Enter your date of birth',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Enter your date of birth')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ const fields: FieldConfig[] = [
+ { key: 'birthdate', label: 'Birth Date', type: FieldType.DATE, disabled: true },
+ ];
+
+ render(
+ ,
+ );
+
+ // Custom dropdown uses tabindex=-1 when disabled
+ expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('displays validation error', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [{ key: 'birthdate', label: 'Birth Date', type: FieldType.DATE }];
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Date is required');
+ });
+ });
+});
+
+describe('FileField edge cases', () => {
+ it('renders with description', () => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'avatar',
+ label: 'Avatar',
+ type: FieldType.FILE,
+ description: 'Upload your profile picture',
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Upload your profile picture')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ const fields: FieldConfig[] = [
+ { key: 'avatar', label: 'Avatar', type: FieldType.FILE, disabled: true },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Avatar')).toBeDisabled();
+ });
+
+ it('handles file upload', async () => {
+ const user = userEvent.setup();
+ const file = new File(['hello'], 'hello.png', { type: 'image/png' });
+
+ const fields: FieldConfig[] = [{ key: 'avatar', label: 'Avatar', type: FieldType.FILE }];
+
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText('Avatar');
+ await user.upload(input, file);
+
+ // Input should have files
+ expect((input as HTMLInputElement).files).toHaveLength(1);
+ expect((input as HTMLInputElement).files?.[0]).toBe(file);
+ });
+});
diff --git a/src/components/fields/__tests__/MultiSelectField.test.tsx b/src/components/fields/__tests__/MultiSelectField.test.tsx
new file mode 100644
index 0000000..c6b45ff
--- /dev/null
+++ b/src/components/fields/__tests__/MultiSelectField.test.tsx
@@ -0,0 +1,249 @@
+/**
+ * Tests for MultiSelectField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('MultiSelectField', () => {
+ const defaultOptions = [
+ { label: 'Apple', value: 'apple' },
+ { label: 'Banana', value: 'banana' },
+ { label: 'Cherry', value: 'cherry' },
+ { label: 'Date', value: 'date' },
+ { label: 'Elderberry', value: 'elderberry' },
+ ];
+
+ const renderMultiSelectField = (
+ fieldConfig: Partial = {},
+ defaultValue: string[] = [],
+ ) => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'fruits',
+ label: 'Fruits',
+ type: FieldType.MULTI_SELECT,
+ options: defaultOptions,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders multiselect with placeholder', () => {
+ renderMultiSelectField({ placeholder: 'Choose fruits...' });
+ expect(screen.getByText('Choose fruits...')).toBeInTheDocument();
+ });
+
+ it('displays selected values as tags', () => {
+ renderMultiSelectField({}, ['apple', 'banana']);
+ expect(screen.getByText('Apple')).toBeInTheDocument();
+ expect(screen.getByText('Banana')).toBeInTheDocument();
+ });
+
+ it('opens dropdown on click', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField();
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
+ });
+
+ it('selects option on click', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField();
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+
+ // Click on the option's parent li element
+ const options = screen.getAllByRole('option');
+ await user.click(options[0]); // Apple
+
+ // Should show the tag (look for Remove button which indicates tag exists)
+ expect(screen.getByRole('button', { name: 'Remove Apple' })).toBeInTheDocument();
+ });
+
+ it('deselects option on second click', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField({}, ['apple']);
+
+ // Initially has Apple tag
+ expect(screen.getByRole('button', { name: 'Remove Apple' })).toBeInTheDocument();
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+
+ // Click on Apple option to deselect
+ const options = screen.getAllByRole('option');
+ await user.click(options[0]); // Apple
+
+ // Tag should be removed
+ expect(screen.queryByRole('button', { name: 'Remove Apple' })).not.toBeInTheDocument();
+ });
+
+ it('removes tag via X button', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField({}, ['apple', 'banana']);
+
+ const removeButton = screen.getByRole('button', { name: 'Remove Apple' });
+ await user.click(removeButton);
+
+ // Apple tag should be removed
+ expect(screen.queryByRole('button', { name: 'Remove Apple' })).not.toBeInTheDocument();
+ // Banana should still exist
+ expect(screen.getByText('Banana')).toBeInTheDocument();
+ });
+
+ it('clears all selections via clear button', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField({}, ['apple', 'banana', 'cherry']);
+
+ const clearButton = screen.getByRole('button', { name: 'Clear selection' });
+ await user.click(clearButton);
+
+ // All tags should be removed
+ expect(screen.queryByRole('button', { name: /Remove/i })).not.toBeInTheDocument();
+ expect(screen.getByText('Select an option...')).toBeInTheDocument();
+ });
+
+ it('filters options via search', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField();
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+
+ const searchInput = screen.getByPlaceholderText('Search...');
+ await user.type(searchInput, 'ban');
+
+ const listbox = screen.getByRole('listbox');
+ expect(within(listbox).getByText('Banana')).toBeInTheDocument();
+ expect(within(listbox).queryByText('Apple')).not.toBeInTheDocument();
+ });
+
+ it('shows "No options found" when search has no matches', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField();
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+
+ const searchInput = screen.getByPlaceholderText('Search...');
+ await user.type(searchInput, 'xyz');
+
+ expect(screen.getByText('No options found')).toBeInTheDocument();
+ });
+
+ it('displays selection count', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField({}, ['apple', 'banana']);
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+
+ expect(screen.getByText('2 selected')).toBeInTheDocument();
+ });
+
+ it('supports keyboard navigation to open dropdown', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField();
+
+ const control = screen.getByRole('combobox');
+ control.focus();
+ await user.keyboard('{ArrowDown}');
+
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ });
+
+ it('navigates options with arrow keys', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField();
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{Enter}');
+
+ // Second option (Banana) should be selected - verify via remove button on tag
+ expect(screen.getByLabelText('Remove Banana')).toBeInTheDocument();
+ });
+
+ it('closes dropdown with Escape', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField();
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ it('shows required indicator', () => {
+ renderMultiSelectField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('shows description', () => {
+ renderMultiSelectField({ description: 'Select your favorite fruits' });
+ expect(screen.getByText('Select your favorite fruits')).toBeInTheDocument();
+ });
+
+ it('disables interaction when disabled', () => {
+ renderMultiSelectField({ disabled: true }, ['apple']);
+ const control = screen.getByRole('combobox');
+ expect(control).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('has proper aria attributes', () => {
+ renderMultiSelectField({ required: true });
+ const control = screen.getByRole('combobox');
+
+ expect(control).toHaveAttribute('aria-haspopup', 'listbox');
+ expect(control).toHaveAttribute('aria-expanded', 'false');
+ expect(control).toHaveAttribute('aria-required', 'true');
+ });
+
+ it('updates aria-expanded when dropdown opens', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField();
+
+ const control = screen.getByRole('combobox');
+ expect(control).toHaveAttribute('aria-expanded', 'false');
+
+ await user.click(control);
+ expect(control).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('marks selected options with aria-selected', async () => {
+ const user = userEvent.setup();
+ renderMultiSelectField({}, ['apple']);
+
+ const control = screen.getByRole('combobox');
+ await user.click(control);
+
+ const options = screen.getAllByRole('option');
+ const appleOption = options.find((opt) => opt.textContent?.includes('Apple'));
+ expect(appleOption).toHaveAttribute('aria-selected', 'true');
+ });
+});
diff --git a/src/components/fields/__tests__/OTPField.test.tsx b/src/components/fields/__tests__/OTPField.test.tsx
new file mode 100644
index 0000000..f4a2ffc
--- /dev/null
+++ b/src/components/fields/__tests__/OTPField.test.tsx
@@ -0,0 +1,145 @@
+/**
+ * Tests for OTPField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('OTPField', () => {
+ const renderOTPField = (fieldConfig: Partial = {}, defaultValue = '') => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'code',
+ label: 'Verification Code',
+ type: FieldType.OTP,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders 6 input boxes by default', () => {
+ renderOTPField();
+ const inputs = screen.getAllByRole('textbox');
+ expect(inputs).toHaveLength(6);
+ });
+
+ it('renders custom number of inputs via otpLength', () => {
+ renderOTPField({ otpLength: 4 });
+ const inputs = screen.getAllByRole('textbox');
+ expect(inputs).toHaveLength(4);
+ });
+
+ it('accepts single digit input', async () => {
+ const user = userEvent.setup();
+ renderOTPField();
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], '5');
+
+ expect(inputs[0]).toHaveValue('5');
+ });
+
+ it('auto-advances focus to next input', async () => {
+ const user = userEvent.setup();
+ renderOTPField();
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], '1');
+
+ expect(inputs[1]).toHaveFocus();
+ });
+
+ it('handles backspace to previous input', async () => {
+ const user = userEvent.setup();
+ renderOTPField({}, '12');
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.click(inputs[2]);
+ await user.keyboard('{Backspace}');
+
+ expect(inputs[1]).toHaveFocus();
+ });
+
+ it('handles paste to fill all inputs', async () => {
+ renderOTPField();
+ const inputs = screen.getAllByRole('textbox');
+
+ // Simulate paste
+ fireEvent.paste(inputs[0], {
+ clipboardData: { getData: () => '123456' },
+ });
+
+ expect(inputs[0]).toHaveValue('1');
+ expect(inputs[1]).toHaveValue('2');
+ expect(inputs[2]).toHaveValue('3');
+ expect(inputs[3]).toHaveValue('4');
+ expect(inputs[4]).toHaveValue('5');
+ expect(inputs[5]).toHaveValue('6');
+ });
+
+ it('filters non-numeric input', async () => {
+ const user = userEvent.setup();
+ renderOTPField();
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.type(inputs[0], 'abc');
+
+ expect(inputs[0]).toHaveValue('');
+ });
+
+ it('supports arrow key navigation', async () => {
+ const user = userEvent.setup();
+ renderOTPField({}, '123456');
+
+ const inputs = screen.getAllByRole('textbox');
+ await user.click(inputs[2]);
+ await user.keyboard('{ArrowLeft}');
+
+ expect(inputs[1]).toHaveFocus();
+
+ await user.keyboard('{ArrowRight}');
+ expect(inputs[2]).toHaveFocus();
+ });
+
+ it('shows required indicator', () => {
+ renderOTPField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('disables all inputs when disabled', () => {
+ renderOTPField({ disabled: true });
+ const inputs = screen.getAllByRole('textbox');
+ inputs.forEach((input) => {
+ expect(input).toBeDisabled();
+ });
+ });
+
+ it('has proper aria-label for each input', () => {
+ renderOTPField({ otpLength: 4 });
+ expect(screen.getByLabelText('Digit 1 of 4')).toBeInTheDocument();
+ expect(screen.getByLabelText('Digit 4 of 4')).toBeInTheDocument();
+ });
+
+ it('displays pre-filled value', () => {
+ renderOTPField({}, '1234');
+ const inputs = screen.getAllByRole('textbox');
+ expect(inputs[0]).toHaveValue('1');
+ expect(inputs[1]).toHaveValue('2');
+ expect(inputs[2]).toHaveValue('3');
+ expect(inputs[3]).toHaveValue('4');
+ });
+});
diff --git a/src/components/fields/__tests__/PasswordField.test.tsx b/src/components/fields/__tests__/PasswordField.test.tsx
new file mode 100644
index 0000000..957f221
--- /dev/null
+++ b/src/components/fields/__tests__/PasswordField.test.tsx
@@ -0,0 +1,105 @@
+/**
+ * Tests for PasswordField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('PasswordField', () => {
+ const renderPasswordField = (fieldConfig: Partial = {}, defaultValue = '') => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'password',
+ label: 'Password',
+ type: FieldType.PASSWORD,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders password input with hidden value by default', () => {
+ renderPasswordField();
+ const input = screen.getByLabelText('Password');
+ expect(input).toHaveAttribute('type', 'password');
+ });
+
+ it('toggles password visibility when clicking show button', async () => {
+ const user = userEvent.setup();
+ renderPasswordField();
+
+ const input = screen.getByLabelText('Password');
+ const toggleButton = screen.getByRole('button', { name: /show password/i });
+
+ expect(input).toHaveAttribute('type', 'password');
+
+ await user.click(toggleButton);
+ expect(input).toHaveAttribute('type', 'text');
+
+ await user.click(toggleButton);
+ expect(input).toHaveAttribute('type', 'password');
+ });
+
+ it('accepts user input', async () => {
+ const user = userEvent.setup();
+ renderPasswordField();
+
+ const input = screen.getByLabelText('Password');
+ await user.type(input, 'secret123');
+
+ expect(input).toHaveValue('secret123');
+ });
+
+ it('shows placeholder when provided', () => {
+ renderPasswordField({ placeholder: 'Enter password' });
+ expect(screen.getByPlaceholderText('Enter password')).toBeInTheDocument();
+ });
+
+ it('shows required indicator when required', () => {
+ renderPasswordField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('disables input when disabled is true', () => {
+ renderPasswordField({ disabled: true });
+ expect(screen.getByLabelText('Password')).toBeDisabled();
+ });
+
+ it('disables toggle button when field is disabled', () => {
+ renderPasswordField({ disabled: true });
+ expect(screen.getByRole('button', { name: /show password/i })).toBeDisabled();
+ });
+
+ it('has proper aria attributes', () => {
+ renderPasswordField({ required: true, description: 'Min 8 chars' });
+
+ // Use regex to match label text that may include required asterisk
+ const input = screen.getByLabelText(/Password/);
+ expect(input).toHaveAttribute('aria-required', 'true');
+ expect(screen.getByText('Min 8 chars')).toBeInTheDocument();
+ });
+
+ it('toggle button has aria-pressed attribute', async () => {
+ const user = userEvent.setup();
+ renderPasswordField();
+
+ const toggle = screen.getByRole('button', { name: /show password/i });
+ expect(toggle).toHaveAttribute('aria-pressed', 'false');
+
+ await user.click(toggle);
+ expect(toggle).toHaveAttribute('aria-pressed', 'true');
+ });
+});
diff --git a/src/components/fields/__tests__/PhoneField.test.tsx b/src/components/fields/__tests__/PhoneField.test.tsx
new file mode 100644
index 0000000..d187a15
--- /dev/null
+++ b/src/components/fields/__tests__/PhoneField.test.tsx
@@ -0,0 +1,169 @@
+/**
+ * Tests for PhoneField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('PhoneField', () => {
+ const DEFAULT_PHONE_VALUE = { countryCode: 'US', dialCode: '+1', number: '' };
+
+ const renderPhoneField = (
+ fieldConfig: Partial = {},
+ defaultValue = DEFAULT_PHONE_VALUE,
+ ) => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'phone',
+ label: 'Phone Number',
+ type: FieldType.PHONE,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders phone input with country selector', () => {
+ renderPhoneField();
+ expect(screen.getByLabelText('Phone Number')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Select country/i })).toBeInTheDocument();
+ });
+
+ it('displays country flag', () => {
+ renderPhoneField();
+ // Flag images are aria-hidden with empty alt for a11y
+ const images = document.querySelectorAll('img[aria-hidden="true"]');
+ expect(images.length).toBeGreaterThan(0);
+ expect(images[0]).toHaveAttribute('src', expect.stringContaining('flagcdn.com'));
+ });
+
+ it('displays dial code', () => {
+ renderPhoneField({}, { countryCode: 'US', dialCode: '+1', number: '' });
+ expect(screen.getByText('+1')).toBeInTheDocument();
+ });
+
+ it('accepts phone number input', async () => {
+ const user = userEvent.setup();
+ renderPhoneField();
+
+ const input = screen.getByLabelText('Phone Number');
+ await user.type(input, '5551234567');
+
+ expect(input).toHaveValue('5551234567');
+ });
+
+ it('opens country dropdown on selector click', async () => {
+ const user = userEvent.setup();
+ renderPhoneField();
+
+ const selector = screen.getByRole('button', { name: /Select country/i });
+ await user.click(selector);
+
+ // Should show country options listbox
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ });
+
+ it('selects a country from dropdown', async () => {
+ const user = userEvent.setup();
+ renderPhoneField();
+
+ // Open dropdown
+ const selector = screen.getByRole('button', { name: /Select country/i });
+ await user.click(selector);
+
+ // Click on UK option
+ const ukOption = screen.getByText('United Kingdom');
+ await user.click(ukOption);
+
+ // Should show UK dial code
+ expect(screen.getByText('+44')).toBeInTheDocument();
+ });
+
+ it('closes dropdown on country selection', async () => {
+ const user = userEvent.setup();
+ renderPhoneField();
+
+ const selector = screen.getByRole('button', { name: /Select country/i });
+ await user.click(selector);
+
+ // Listbox should be visible
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+
+ // Select a country
+ await user.click(screen.getByText('Germany'));
+
+ // Dropdown should close (listbox hidden)
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ it('shows required indicator', () => {
+ renderPhoneField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('disables both selector and input when disabled', () => {
+ renderPhoneField({ disabled: true });
+ expect(screen.getByLabelText('Phone Number')).toBeDisabled();
+ // Country selector is a button, not a combobox
+ expect(screen.getByRole('button', { name: /Select country/i })).toBeDisabled();
+ });
+
+ it('shows description', () => {
+ renderPhoneField({ description: 'Include area code' });
+ expect(screen.getByText('Include area code')).toBeInTheDocument();
+ });
+
+ it('preserves phone value structure', async () => {
+ const onSubmit = vi.fn();
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [{ key: 'phone', label: 'Phone', type: FieldType.PHONE }];
+
+ render(
+ ,
+ );
+
+ await user.type(screen.getByLabelText('Phone'), '1234567890');
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ phone: expect.objectContaining({
+ countryCode: 'US',
+ dialCode: '+1',
+ number: '1234567890',
+ }),
+ }),
+ );
+ });
+});
diff --git a/src/components/fields/__tests__/RangeSliderField.test.tsx b/src/components/fields/__tests__/RangeSliderField.test.tsx
new file mode 100644
index 0000000..292da2b
--- /dev/null
+++ b/src/components/fields/__tests__/RangeSliderField.test.tsx
@@ -0,0 +1,108 @@
+/**
+ * Tests for RangeSliderField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('RangeSliderField', () => {
+ const renderRangeSliderField = (
+ fieldConfig: Partial = {},
+ defaultValue = { from: 20, to: 80 },
+ ) => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'priceRange',
+ label: 'Price Range',
+ type: FieldType.RANGE_SLIDER,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders two sliders for from and to values', () => {
+ renderRangeSliderField();
+ const sliders = screen.getAllByRole('slider');
+ expect(sliders).toHaveLength(2);
+ });
+
+ it('displays from and to input values', () => {
+ renderRangeSliderField({ showValue: true }, { from: 25, to: 75 });
+ // Use spinbutton role with specific aria-labels for the number inputs
+ expect(screen.getByRole('spinbutton', { name: /minimum/i })).toHaveValue(25);
+ expect(screen.getByRole('spinbutton', { name: /maximum/i })).toHaveValue(75);
+ });
+
+ it('uses custom min/max values', () => {
+ renderRangeSliderField({ min: 100, max: 500 });
+ const sliders = screen.getAllByRole('slider');
+ sliders.forEach((slider) => {
+ expect(slider).toHaveAttribute('min', '100');
+ expect(slider).toHaveAttribute('max', '500');
+ });
+ });
+
+ it('updates from value via slider', () => {
+ renderRangeSliderField({ showValue: true }, { from: 20, to: 80 });
+ const sliders = screen.getAllByRole('slider');
+
+ fireEvent.change(sliders[0], { target: { value: '30' } });
+ expect(screen.getByRole('spinbutton', { name: /minimum/i })).toHaveValue(30);
+ });
+
+ it('updates to value via slider', () => {
+ renderRangeSliderField({ showValue: true }, { from: 20, to: 80 });
+ const sliders = screen.getAllByRole('slider');
+
+ fireEvent.change(sliders[1], { target: { value: '90' } });
+ expect(screen.getByRole('spinbutton', { name: /maximum/i })).toHaveValue(90);
+ });
+
+ it('prevents from exceeding to value', () => {
+ renderRangeSliderField({ showValue: true }, { from: 20, to: 50 });
+
+ const fromInput = screen.getByRole('spinbutton', { name: /minimum/i });
+ fireEvent.change(fromInput, { target: { value: '60' } });
+
+ // Should clamp to 'to' value minus step (default step is 1)
+ expect(fromInput).toHaveValue(49);
+ });
+
+ it('prevents to going below from value', () => {
+ renderRangeSliderField({ showValue: true }, { from: 40, to: 80 });
+
+ const toInput = screen.getByRole('spinbutton', { name: /maximum/i });
+ fireEvent.change(toInput, { target: { value: '30' } });
+
+ // Should clamp to 'from' value plus step (default step is 1)
+ expect(toInput).toHaveValue(41);
+ });
+
+ it('shows required indicator', () => {
+ renderRangeSliderField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('disables both sliders when disabled', () => {
+ renderRangeSliderField({ disabled: true });
+ const sliders = screen.getAllByRole('slider');
+ sliders.forEach((slider) => {
+ expect(slider).toBeDisabled();
+ });
+ });
+});
diff --git a/src/components/fields/__tests__/RatingField.test.tsx b/src/components/fields/__tests__/RatingField.test.tsx
new file mode 100644
index 0000000..8cc5fa3
--- /dev/null
+++ b/src/components/fields/__tests__/RatingField.test.tsx
@@ -0,0 +1,157 @@
+/**
+ * Tests for RatingField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('RatingField', () => {
+ const renderRatingField = (fieldConfig: Partial = {}, defaultValue = 0) => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'rating',
+ label: 'Rating',
+ type: FieldType.RATING,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders 5 stars by default', () => {
+ renderRatingField();
+ const slider = screen.getByRole('slider');
+ const stars = within(slider).getAllByRole('button', { hidden: true });
+ expect(stars).toHaveLength(5);
+ });
+
+ it('renders custom number of stars via maxRating', () => {
+ renderRatingField({ maxRating: 10 });
+ const slider = screen.getByRole('slider');
+ const stars = within(slider).getAllByRole('button', { hidden: true });
+ expect(stars).toHaveLength(10);
+ });
+
+ it('displays current rating', () => {
+ renderRatingField({}, 3);
+ expect(screen.getByText('3')).toBeInTheDocument();
+ expect(screen.getByText('/ 5')).toBeInTheDocument();
+ });
+
+ it('shows "No rating" when value is 0', () => {
+ renderRatingField({}, 0);
+ expect(screen.getByText('No rating')).toBeInTheDocument();
+ });
+
+ it('sets rating on star click', async () => {
+ const user = userEvent.setup();
+ renderRatingField();
+
+ const slider = screen.getByRole('slider');
+ const stars = within(slider).getAllByRole('button', { hidden: true });
+ await user.click(stars[3]); // Click 4th star
+
+ expect(screen.getByText('4')).toBeInTheDocument();
+ });
+
+ it('clears rating when clicking current rating', async () => {
+ const user = userEvent.setup();
+ renderRatingField({}, 3);
+
+ const slider = screen.getByRole('slider');
+ const stars = within(slider).getAllByRole('button', { hidden: true });
+ await user.click(stars[2]); // Click 3rd star again
+
+ expect(screen.getByText('No rating')).toBeInTheDocument();
+ });
+
+ it('supports keyboard navigation with arrow keys', async () => {
+ const user = userEvent.setup();
+ renderRatingField({}, 2);
+
+ const container = screen.getByRole('slider');
+ await user.click(container);
+ await user.keyboard('{ArrowRight}');
+
+ expect(screen.getByText('3')).toBeInTheDocument();
+
+ await user.keyboard('{ArrowLeft}');
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+
+ it('supports Home and End keys', async () => {
+ const user = userEvent.setup();
+ renderRatingField({}, 3);
+
+ const container = screen.getByRole('slider');
+ await user.click(container);
+ await user.keyboard('{End}');
+
+ expect(screen.getByText('5')).toBeInTheDocument();
+
+ await user.keyboard('{Home}');
+ expect(screen.getByText('No rating')).toBeInTheDocument();
+ });
+
+ it('supports half-star ratings when allowHalf is true', async () => {
+ const user = userEvent.setup();
+ renderRatingField({ allowHalf: true }, 2.5);
+
+ expect(screen.getByText('2.5')).toBeInTheDocument();
+
+ const container = screen.getByRole('slider');
+ await user.click(container);
+ await user.keyboard('{ArrowRight}');
+
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ it('uses 0.5 step when allowHalf is true', async () => {
+ const user = userEvent.setup();
+ renderRatingField({ allowHalf: true }, 2);
+
+ const container = screen.getByRole('slider');
+ await user.click(container);
+ await user.keyboard('{ArrowRight}');
+
+ expect(screen.getByText('2.5')).toBeInTheDocument();
+ });
+
+ it('shows required indicator', () => {
+ renderRatingField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('disables interaction when disabled', () => {
+ renderRatingField({ disabled: true });
+ const slider = screen.getByRole('slider');
+ const stars = within(slider).getAllByRole('button', { hidden: true });
+ expect(stars.length).toBe(5);
+ stars.forEach((star) => {
+ expect(star).toBeDisabled();
+ });
+ });
+
+ it('has proper aria attributes', () => {
+ renderRatingField({ maxRating: 5 }, 3);
+ const slider = screen.getByRole('slider');
+
+ expect(slider).toHaveAttribute('aria-valuemin', '0');
+ expect(slider).toHaveAttribute('aria-valuemax', '5');
+ expect(slider).toHaveAttribute('aria-valuenow', '3');
+ expect(slider).toHaveAttribute('aria-valuetext', '3 out of 5 stars');
+ });
+});
diff --git a/src/components/fields/__tests__/SliderField.test.tsx b/src/components/fields/__tests__/SliderField.test.tsx
new file mode 100644
index 0000000..4eabd49
--- /dev/null
+++ b/src/components/fields/__tests__/SliderField.test.tsx
@@ -0,0 +1,110 @@
+/**
+ * Tests for SliderField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('SliderField', () => {
+ const renderSliderField = (fieldConfig: Partial = {}, defaultValue = 50) => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'volume',
+ label: 'Volume',
+ type: FieldType.SLIDER,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders slider with default value', () => {
+ renderSliderField({}, 75);
+ const slider = screen.getByRole('slider');
+ expect(slider).toHaveValue('75');
+ });
+
+ it('uses custom min/max values', () => {
+ renderSliderField({ min: 10, max: 200 });
+ const slider = screen.getByRole('slider');
+ expect(slider).toHaveAttribute('min', '10');
+ expect(slider).toHaveAttribute('max', '200');
+ });
+
+ it('uses custom step value', () => {
+ renderSliderField({ step: 5 });
+ const slider = screen.getByRole('slider');
+ expect(slider).toHaveAttribute('step', '5');
+ });
+
+ it('shows current value when showValue is true', () => {
+ renderSliderField({ showValue: true }, 42);
+ // Use spinbutton role as it renders a number input
+ expect(screen.getByRole('spinbutton')).toHaveValue(42);
+ });
+
+ it('updates value via slider change', () => {
+ renderSliderField({}, 50);
+ const slider = screen.getByRole('slider');
+
+ fireEvent.change(slider, { target: { value: '80' } });
+ expect(slider).toHaveValue('80');
+ });
+
+ it('syncs editable input with slider', async () => {
+ const user = userEvent.setup();
+ renderSliderField({ showValue: true }, 50);
+
+ // Use spinbutton role for the editable number input
+ const numberInput = screen.getByRole('spinbutton');
+ await user.clear(numberInput);
+ await user.type(numberInput, '75');
+ await user.tab();
+
+ expect(screen.getByRole('slider')).toHaveValue('75');
+ });
+
+ it('clamps value to min/max when editing', async () => {
+ const user = userEvent.setup();
+ renderSliderField({ min: 0, max: 100, showValue: true }, 50);
+
+ // Use spinbutton role for the editable number input
+ const numberInput = screen.getByRole('spinbutton');
+ await user.clear(numberInput);
+ await user.type(numberInput, '150');
+ await user.tab();
+
+ expect(screen.getByRole('slider')).toHaveValue('100');
+ });
+
+ it('shows required indicator', () => {
+ renderSliderField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('disables slider when disabled is true', () => {
+ renderSliderField({ disabled: true });
+ expect(screen.getByRole('slider')).toBeDisabled();
+ });
+
+ it('has proper aria attributes', () => {
+ renderSliderField({ min: 0, max: 100 }, 50);
+ const slider = screen.getByRole('slider');
+ expect(slider).toHaveAttribute('aria-valuemin', '0');
+ expect(slider).toHaveAttribute('aria-valuemax', '100');
+ expect(slider).toHaveAttribute('aria-valuenow', '50');
+ });
+});
diff --git a/src/components/fields/__tests__/TagsField.test.tsx b/src/components/fields/__tests__/TagsField.test.tsx
new file mode 100644
index 0000000..5dc0d3c
--- /dev/null
+++ b/src/components/fields/__tests__/TagsField.test.tsx
@@ -0,0 +1,158 @@
+/**
+ * Tests for TagsField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('TagsField', () => {
+ const renderTagsField = (fieldConfig: Partial = {}, defaultValue: string[] = []) => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'tags',
+ label: 'Tags',
+ type: FieldType.TAGS,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders empty tag input', () => {
+ renderTagsField();
+ expect(screen.getByPlaceholderText('Type and press Enter')).toBeInTheDocument();
+ });
+
+ it('displays existing tags', () => {
+ renderTagsField({}, ['react', 'typescript']);
+ expect(screen.getByText('react')).toBeInTheDocument();
+ expect(screen.getByText('typescript')).toBeInTheDocument();
+ });
+
+ it('adds tag on Enter', async () => {
+ const user = userEvent.setup();
+ renderTagsField();
+
+ const input = screen.getByPlaceholderText('Type and press Enter');
+ await user.type(input, 'newtag{Enter}');
+
+ expect(screen.getByText('newtag')).toBeInTheDocument();
+ });
+
+ it('adds tag on comma', async () => {
+ const user = userEvent.setup();
+ renderTagsField();
+
+ const input = screen.getByPlaceholderText('Type and press Enter');
+ await user.type(input, 'tag1,');
+
+ expect(screen.getByText('tag1')).toBeInTheDocument();
+ });
+
+ it('removes tag on button click', async () => {
+ const user = userEvent.setup();
+ renderTagsField({}, ['removeme']);
+
+ const removeButton = screen.getByRole('button', { name: 'Remove tag: removeme' });
+ await user.click(removeButton);
+
+ expect(screen.queryByText('removeme')).not.toBeInTheDocument();
+ });
+
+ it('removes last tag on backspace when input is empty', async () => {
+ const user = userEvent.setup();
+ renderTagsField({}, ['first', 'second']);
+
+ const input = screen.getByRole('textbox');
+ await user.click(input);
+ await user.keyboard('{Backspace}');
+
+ expect(screen.queryByText('second')).not.toBeInTheDocument();
+ expect(screen.getByText('first')).toBeInTheDocument();
+ });
+
+ it('handles paste with comma-separated values', () => {
+ renderTagsField();
+ const input = screen.getByRole('textbox');
+
+ fireEvent.paste(input, {
+ clipboardData: { getData: () => 'one,two,three' },
+ });
+
+ expect(screen.getByText('one')).toBeInTheDocument();
+ expect(screen.getByText('two')).toBeInTheDocument();
+ expect(screen.getByText('three')).toBeInTheDocument();
+ });
+
+ it('respects maxTags limit', async () => {
+ const user = userEvent.setup();
+ renderTagsField({ maxTags: 2 }, ['existing']);
+
+ const input = screen.getByRole('textbox');
+ await user.type(input, 'second{Enter}');
+ expect(screen.getByText('second')).toBeInTheDocument();
+
+ // Third tag should not be added
+ await user.type(input, 'third{Enter}');
+ expect(screen.queryByText('third')).not.toBeInTheDocument();
+ });
+
+ it('respects minTags and prevents deletion below minimum', () => {
+ renderTagsField({ minTags: 1 }, ['required']);
+
+ // Remove button should not be visible when at minimum
+ expect(screen.queryByRole('button', { name: 'Remove required' })).not.toBeInTheDocument();
+ });
+
+ it('allows duplicates by default', async () => {
+ const user = userEvent.setup();
+ renderTagsField({}, ['existing']);
+
+ const input = screen.getByRole('textbox');
+ await user.type(input, 'existing{Enter}');
+
+ // Default is allowDuplicates: true, so should have two "existing" tags
+ const tags = screen.getAllByRole('option');
+ expect(tags).toHaveLength(2);
+ });
+
+ it('prevents duplicates when allowDuplicates is false', async () => {
+ const user = userEvent.setup();
+ renderTagsField({ allowDuplicates: false }, ['tag']);
+
+ const input = screen.getByRole('textbox');
+ await user.type(input, 'tag{Enter}');
+
+ // Should still only have one "tag"
+ const tags = screen.getAllByRole('option');
+ expect(tags).toHaveLength(1);
+ });
+
+ it('shows count when maxTags is set', () => {
+ renderTagsField({ maxTags: 5 }, ['one', 'two']);
+ expect(screen.getByText('2/5 tags')).toBeInTheDocument();
+ });
+
+ it('shows required indicator', () => {
+ renderTagsField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('disables input when disabled', () => {
+ renderTagsField({ disabled: true });
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/fields/__tests__/TimeField.test.tsx b/src/components/fields/__tests__/TimeField.test.tsx
new file mode 100644
index 0000000..11656ef
--- /dev/null
+++ b/src/components/fields/__tests__/TimeField.test.tsx
@@ -0,0 +1,208 @@
+/**
+ * Tests for TimeField component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+import DynamicForm from '../../form/DynamicForm';
+
+describe('TimeField', () => {
+ const renderTimeField = (fieldConfig: Partial = {}, defaultValue = '') => {
+ const fields: FieldConfig[] = [
+ {
+ key: 'startTime',
+ label: 'Start Time',
+ type: FieldType.TIME,
+ ...fieldConfig,
+ },
+ ];
+
+ return render(
+ ,
+ );
+ };
+
+ it('renders time picker', () => {
+ renderTimeField();
+ const combobox = screen.getByRole('combobox');
+ expect(combobox).toBeInTheDocument();
+ expect(combobox).toHaveAttribute('aria-haspopup', 'dialog');
+ });
+
+ it('displays default value formatted', () => {
+ renderTimeField({}, '14:30');
+ expect(screen.getByText('2:30 PM')).toBeInTheDocument();
+ });
+
+ it('opens time picker dropdown on click', async () => {
+ const user = userEvent.setup();
+ renderTimeField();
+
+ const combobox = screen.getByRole('combobox');
+ await user.click(combobox);
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('Hour')).toBeInTheDocument();
+ expect(screen.getByText('Minute')).toBeInTheDocument();
+ });
+
+ it('selects time via dropdown', async () => {
+ const user = userEvent.setup();
+ renderTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+
+ // The time picker shows hour and minute columns with confirm button
+ const buttons = screen.getAllByRole('button');
+ expect(buttons.length).toBeGreaterThan(0);
+
+ const confirmBtn = screen.getByRole('button', { name: 'Confirm' });
+ expect(confirmBtn).toBeInTheDocument();
+ });
+
+ it('shows required indicator', () => {
+ renderTimeField({ required: true });
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+
+ it('shows description', () => {
+ renderTimeField({ description: 'Select appointment time' });
+ expect(screen.getByText('Select appointment time')).toBeInTheDocument();
+ });
+
+ it('disables interaction when disabled', () => {
+ renderTimeField({ disabled: true });
+ expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('does not open dropdown when readOnly', async () => {
+ const user = userEvent.setup();
+ renderTimeField({ readOnly: true });
+
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('has proper aria attributes', () => {
+ renderTimeField({ required: true });
+ const combobox = screen.getByRole('combobox');
+ expect(combobox).toHaveAttribute('aria-required', 'true');
+ });
+
+ it('closes dropdown on escape', async () => {
+ const user = userEvent.setup();
+ renderTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('supports keyboard navigation and confirms selection', async () => {
+ const user = userEvent.setup();
+ renderTimeField();
+
+ const combobox = screen.getByRole('combobox');
+ combobox.focus();
+
+ await user.keyboard('{ArrowDown}'); // open
+ await user.keyboard('{ArrowRight}'); // move to minute column
+ await user.keyboard('{ArrowDown}'); // increment minute
+ await user.keyboard('{Enter}'); // confirm
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows clear button for existing value and clears it', async () => {
+ const user = userEvent.setup();
+ renderTimeField({}, '09:15');
+
+ const clearButton = screen.getByRole('button', { name: 'Clear selection' });
+ await user.click(clearButton);
+
+ expect(screen.queryByText('9:15 AM')).not.toBeInTheDocument();
+ });
+
+ it('respects timeStep minute interval options', async () => {
+ const user = userEvent.setup();
+ renderTimeField({ timeStep: 900 }); // 15-minute interval
+
+ await user.click(screen.getByRole('combobox'));
+
+ expect(screen.getByRole('button', { name: '00' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '15' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '45' })).toBeInTheDocument();
+ });
+
+ it('handles arrow up navigation for hour and minute columns', async () => {
+ const user = userEvent.setup();
+ renderTimeField();
+
+ const combobox = screen.getByRole('combobox');
+ combobox.focus();
+
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{ArrowUp}');
+ await user.keyboard('{ArrowRight}');
+ await user.keyboard('{ArrowUp}');
+ await user.keyboard('{Enter}');
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('cycles focus column with Tab while open', async () => {
+ const user = userEvent.setup();
+ renderTimeField();
+
+ await user.click(screen.getByRole('combobox'));
+ await user.keyboard('{Tab}');
+ await user.keyboard('{ArrowDown}');
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('toggles dropdown open and closed on combobox click', async () => {
+ const user = userEvent.setup();
+ renderTimeField();
+
+ const combobox = screen.getByRole('combobox');
+ await user.click(combobox);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.click(combobox);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('supports space key interaction to open and confirm', async () => {
+ const user = userEvent.setup();
+ renderTimeField();
+
+ const combobox = screen.getByRole('combobox');
+ combobox.focus();
+
+ await user.keyboard(' ');
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.keyboard(' ');
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts
new file mode 100644
index 0000000..30118b0
--- /dev/null
+++ b/src/components/fields/index.ts
@@ -0,0 +1,32 @@
+/**
+ * Fields module exports
+ */
+
+export { default as Field, type FieldProps } from './Field';
+export { default as TextField, type TextFieldProps } from './TextField';
+export { default as PasswordField, type PasswordFieldProps } from './PasswordField';
+export { default as TextareaField, type TextareaFieldProps } from './TextareaField';
+export { default as SelectField, type SelectFieldProps } from './SelectField';
+export { default as CheckboxField, type CheckboxFieldProps } from './CheckboxField';
+export { default as RadioGroupField, type RadioGroupFieldProps } from './RadioGroupField';
+export { default as SwitchField, type SwitchFieldProps } from './SwitchField';
+export { default as DateField, type DateFieldProps } from './DateField';
+export { default as FileField, type FileFieldProps } from './FileField';
+export {
+ default as PhoneField,
+ type PhoneFieldProps,
+ type PhoneValue,
+ type CountryCode,
+} from './PhoneField';
+export { default as SliderField, type SliderFieldProps } from './SliderField';
+export {
+ default as RangeSliderField,
+ type RangeSliderFieldProps,
+ type RangeValue,
+} from './RangeSliderField';
+export { default as OTPField, type OTPFieldProps } from './OTPField';
+export { default as TagsField, type TagsFieldProps } from './TagsField';
+export { default as RatingField, type RatingFieldProps } from './RatingField';
+export { default as TimeField, type TimeFieldProps } from './TimeField';
+export { default as DateTimeField, type DateTimeFieldProps } from './DateTimeField';
+export { default as ArrayField, type ArrayFieldProps } from './ArrayField';
diff --git a/src/components/form/DynamicForm.tsx b/src/components/form/DynamicForm.tsx
new file mode 100644
index 0000000..bcd66b5
--- /dev/null
+++ b/src/components/form/DynamicForm.tsx
@@ -0,0 +1,469 @@
+/**
+ * DynamicForm - THE primary public component
+ * Auto-generates accessible, validated forms from Zod schema + field config
+ */
+
+import { type JSX, type FormEvent, useState, useCallback, useMemo } from 'react';
+import type { z } from 'zod';
+import type { FieldConfig } from '../../models/FieldConfig';
+import type { StepConfig } from '../../models/StepConfig';
+import type { FormLayoutItem, ColumnCount } from '../../models/SectionConfig';
+import { isSection } from '../../models/SectionConfig';
+import type { FormValues, ValidationMode, FieldValue } from '../../core/types';
+import type { Locale, TranslationKeys } from '../../core/i18n';
+import type { FieldErrors } from '../../core/validator';
+import { getGridContainerClass, getColSpanClass } from '../../core/grid';
+import FormKitProvider from '../context/FormKitContext';
+import I18nProvider from '../context/I18nContext';
+import { useI18n } from '../../hooks/useI18n';
+import Field from '../fields/Field';
+import FormSection from '../layout/FormSection';
+import FormActions from '../layout/FormActions';
+
+/**
+ * Scrolls to the first field with an error after validation fails.
+ * Uses requestAnimationFrame + setTimeout to ensure DOM is updated after state changes.
+ */
+function scrollToFirstError(fieldKeys: string[]): void {
+ // Wait for React to flush state updates and render error elements
+ requestAnimationFrame(() => {
+ // Additional delay to ensure DOM is fully updated
+ setTimeout(() => {
+ for (const key of fieldKeys) {
+ const fieldElement = document.getElementById(`field-${key}`);
+ if (fieldElement) {
+ // Check if scrollIntoView is available (not in jsdom test environment)
+ if (typeof fieldElement.scrollIntoView === 'function') {
+ fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ // Focus the field for accessibility
+ if (typeof fieldElement.focus === 'function') {
+ fieldElement.focus({ preventScroll: true });
+ }
+ break;
+ }
+ }
+ }, 50);
+ });
+}
+
+/**
+ * Props for DynamicForm component
+ *
+ * @example β Single page form
+ * ```tsx
+ * saveUser(values)}
+ * />
+ * ```
+ *
+ * @example β Multi-step wizard
+ * ```tsx
+ *
+ * ```
+ */
+export interface DynamicFormProps {
+ // ββ Core ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Zod schema for single-page mode (omit when using `steps`) */
+ schema?: z.ZodType;
+ /**
+ * Field configuration array for single-page mode (omit when using `steps`).
+ * For advanced layouts, use `layout` instead.
+ */
+ fields?: FieldConfig[];
+ /**
+ * Layout configuration with sections and fields.
+ * Supports grouping fields into sections with independent grid configs.
+ * Takes precedence over `fields` when both are provided.
+ *
+ * @example
+ * ```tsx
+ * layout={[
+ * {
+ * type: 'section',
+ * title: 'Personal Info',
+ * columns: { default: 1, md: 2 },
+ * fields: [
+ * { key: 'firstName', type: FieldType.TEXT, colSpan: 1 },
+ * { key: 'lastName', type: FieldType.TEXT, colSpan: 1 },
+ * ]
+ * },
+ * { key: 'notes', type: FieldType.TEXTAREA }, // standalone field
+ * ]}
+ * ```
+ */
+ layout?: FormLayoutItem[];
+ /** Step configuration array for wizard mode (replaces `schema` + `fields`) */
+ steps?: StepConfig[];
+ /** Initial field values */
+ defaultValues: Partial;
+
+ // ββ Callbacks βββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Called with validated values on successful submission */
+ onSubmit: (values: TValues) => Promise | void;
+ /** Called when validation fails */
+ onError?: (errors: FieldErrors) => void;
+ /** Called when any field value changes */
+ onChange?: (values: Partial) => void;
+
+ // ββ Behavior ββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Validation trigger mode (default: 'onBlur') */
+ mode?: ValidationMode;
+ /** Reset form after successful submit (default: false) */
+ resetOnSubmit?: boolean;
+
+ // ββ Labels ββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Submit button label (default: 'Submit') */
+ submitLabel?: string;
+ /** Reset button label (omit to hide reset button) */
+ resetLabel?: string;
+ /** Next button label for wizard mode (default: 'Next') */
+ nextLabel?: string;
+ /** Back button label for wizard mode (default: 'Back') */
+ prevLabel?: string;
+
+ // ββ Layout ββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Show step indicator in wizard mode (default: true) */
+ showStepper?: boolean;
+ /**
+ * Grid columns for field layout.
+ * Can be a number (1-12) or responsive config.
+ * Individual sections can override this.
+ * @default 1
+ *
+ * @example Simple: `columns={2}`
+ * @example Responsive: `columns={{ default: 1, md: 2, lg: 3 }}`
+ */
+ columns?: ColumnCount;
+ /** Gap between fields (Tailwind scale: 1-8, default: 4) */
+ gap?: 1 | 2 | 3 | 4 | 5 | 6 | 8;
+ /** Custom CSS class */
+ className?: string;
+
+ // ββ Internationalization ββββββββββββββββββββββββββββββββββββ
+ /** Locale for translations (default: 'en') */
+ locale?: Locale;
+ /** Custom translations to override defaults */
+ customTranslations?: Partial;
+}
+
+/**
+ * DynamicForm - Auto-generates a fully accessible, validated form from a Zod schema and field config.
+ * Supports single-page forms and multi-step wizards via the `steps` prop.
+ *
+ * This is the ONLY public form component. All form rendering goes through DynamicForm.
+ */
+export default function DynamicForm({
+ schema,
+ fields,
+ layout,
+ steps,
+ defaultValues,
+ onSubmit,
+ onError,
+ onChange,
+ // mode is accepted but not yet implemented
+ resetOnSubmit = false,
+ submitLabel,
+ resetLabel,
+ nextLabel,
+ prevLabel,
+ showStepper = true,
+ columns = 1,
+ gap = 4,
+ className = '',
+ locale = 'en',
+ customTranslations,
+}: DynamicFormProps): JSX.Element {
+ return (
+
+
+
+ );
+}
+
+/**
+ * Inner form component that has access to i18n context
+ */
+function DynamicFormInner({
+ schema,
+ fields,
+ layout,
+ steps,
+ defaultValues,
+ onSubmit,
+ onError,
+ onChange,
+ resetOnSubmit = false,
+ submitLabel,
+ resetLabel,
+ nextLabel,
+ prevLabel,
+ showStepper = true,
+ columns = 1,
+ gap = 4,
+ className = '',
+}: Omit, 'locale' | 'customTranslations'>): JSX.Element {
+ const { t } = useI18n();
+ // ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ const [values, setValues] = useState>(defaultValues);
+ const [errors, setErrors] = useState>({});
+ const [touched, setTouched] = useState>>({});
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [currentStep, setCurrentStep] = useState(0);
+
+ // ββ Determine mode (single-page vs wizard) ββββββββββββββββββββ
+ const isWizardMode = !!steps && steps.length > 0;
+ const resolvedSchema = isWizardMode ? steps[currentStep]?.schema : schema;
+
+ // Resolve layout items: layout > fields > step fields
+ const resolvedLayoutItems = useMemo(() => {
+ if (isWizardMode) {
+ return steps?.[currentStep]?.fields ?? [];
+ }
+ if (layout && layout.length > 0) {
+ return layout;
+ }
+ return fields ?? [];
+ }, [isWizardMode, steps, currentStep, layout, fields]);
+
+ // Extract flat list of field configs for validation (needed for error scrolling)
+ const resolvedFields = useMemo(() => {
+ const flatFields: FieldConfig[] = [];
+ for (const item of resolvedLayoutItems) {
+ if (isSection(item)) {
+ flatFields.push(...item.fields);
+ } else {
+ flatFields.push(item);
+ }
+ }
+ return flatFields;
+ }, [resolvedLayoutItems]);
+
+ // ββ Form context value ββββββββββββββββββββββββββββββββββββββββ
+ const contextValue = useMemo(
+ () => ({
+ getValue: (key: keyof TValues) => values[key],
+ setValue: (key: keyof TValues, value: FieldValue) => {
+ setValues((prev) => {
+ const next = { ...prev, [key]: value };
+ onChange?.(next);
+ return next;
+ });
+ // Clear error on change
+ if (errors[key]) {
+ setErrors((prev) => ({ ...prev, [key]: undefined }));
+ }
+ },
+ getError: (key: keyof TValues) => errors[key] ?? null,
+ setError: (key: keyof TValues, error: string | null) => {
+ setErrors((prev) => ({ ...prev, [key]: error ?? undefined }));
+ },
+ getTouched: (key: keyof TValues) => touched[key] ?? false,
+ setTouched: (key: keyof TValues, isTouched: boolean) => {
+ setTouched((prev) => ({ ...prev, [key]: isTouched }));
+ },
+ getValues: () => values as TValues,
+ isSubmitting,
+ isValid: Object.keys(errors).length === 0,
+ }),
+ [values, errors, touched, isSubmitting, onChange],
+ );
+
+ // ββ Submit handler ββββββββββββββββββββββββββββββββββββββββββββ
+ const handleSubmit = useCallback(
+ async (e: FormEvent) => {
+ e.preventDefault();
+
+ if (!resolvedSchema) {
+ // No schema, just call onSubmit
+ await onSubmit(values as TValues);
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ // Validate with Zod
+ const result = resolvedSchema.safeParse(values);
+
+ if (!result.success) {
+ // Map Zod errors to field errors
+ const fieldErrors: FieldErrors = {};
+ for (const issue of result.error.issues) {
+ const key = issue.path.join('.') as keyof TValues;
+ if (!(key in fieldErrors)) {
+ fieldErrors[key] = issue.message;
+ }
+ }
+ setErrors(fieldErrors);
+ // Mark fields with errors as touched so errors display
+ const touchedFields: Partial> = {};
+ Object.keys(fieldErrors).forEach((key) => {
+ touchedFields[key as keyof TValues] = true;
+ });
+ setTouched((prev) => ({ ...prev, ...touchedFields }));
+
+ // Scroll to first field with error (in field order)
+ const errorKeys = resolvedFields.map((f) => f.key).filter((key) => key in fieldErrors);
+ if (errorKeys.length > 0) {
+ scrollToFirstError(errorKeys);
+ }
+
+ onError?.(fieldErrors);
+ return;
+ }
+
+ // If wizard mode and not on last step, advance to next step
+ if (isWizardMode && steps && currentStep < steps.length - 1) {
+ setCurrentStep((prev) => prev + 1);
+ return;
+ }
+
+ // Submit
+ await onSubmit(result.data as TValues);
+
+ // Reset if configured
+ if (resetOnSubmit) {
+ setValues(defaultValues);
+ setErrors({});
+ setTouched({});
+ if (isWizardMode) setCurrentStep(0);
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [
+ values,
+ resolvedSchema,
+ onSubmit,
+ onError,
+ isWizardMode,
+ steps,
+ currentStep,
+ resetOnSubmit,
+ defaultValues,
+ resolvedFields,
+ ],
+ );
+
+ // ββ Reset handler βββββββββββββββββββββββββββββββββββββββββββββ
+ const handleReset = useCallback(() => {
+ setValues(defaultValues);
+ setErrors({});
+ setTouched({});
+ if (isWizardMode) setCurrentStep(0);
+ }, [defaultValues, isWizardMode]);
+
+ // ββ Step navigation (wizard mode) βββββββββββββββββββββββββββββ
+ const handlePrev = useCallback(() => {
+ if (currentStep > 0) {
+ setCurrentStep((prev) => prev - 1);
+ }
+ }, [currentStep]);
+
+ // ββ Grid class ββββββββββββββββββββββββββββββββββββββββββββββββ
+ const gridClass = getGridContainerClass(columns, gap);
+
+ // Check if we have any sections (to determine if we need a wrapper grid)
+ const hasSections = resolvedLayoutItems.some(isSection);
+
+ // ββ Resolved labels with i18n ββββββββββββββββββββββββββββββββ
+ const resolvedSubmitLabel = submitLabel ?? t('form.submit');
+ const resolvedNextLabel = nextLabel ?? t('form.next');
+ const resolvedPrevLabel = prevLabel ?? t('form.back');
+
+ // ββ Render ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ return (
+
+
+
+ );
+}
diff --git a/src/components/form/DynamicFormStep.tsx b/src/components/form/DynamicFormStep.tsx
new file mode 100644
index 0000000..7463009
--- /dev/null
+++ b/src/components/form/DynamicFormStep.tsx
@@ -0,0 +1,46 @@
+/**
+ * DynamicFormStep - Renders a single step's fields (wizard mode)
+ * Internal component used by DynamicForm
+ */
+
+import type { JSX } from 'react';
+import type { StepConfig } from '../../models/StepConfig';
+import Field from '../fields/Field';
+
+/**
+ * Props for DynamicFormStep
+ */
+type Props = {
+ /** Step configuration */
+ step: StepConfig;
+ /** Whether this step is currently active */
+ isActive: boolean;
+};
+
+/**
+ * Renders the fields for a single form step
+ * Hidden when not active to preserve field state
+ *
+ * @internal
+ */
+export default function DynamicFormStep({ step, isActive }: Props): JSX.Element {
+ return (
+
+ {step.description && (
+
{step.description}
+ )}
+
+ {step.fields.map((field) => (
+
+ ))}
+
+
+ );
+}
+
+export type { Props as DynamicFormStepProps };
diff --git a/src/components/form/FormStepper.tsx b/src/components/form/FormStepper.tsx
new file mode 100644
index 0000000..1e866b0
--- /dev/null
+++ b/src/components/form/FormStepper.tsx
@@ -0,0 +1,164 @@
+/**
+ * FormStepper - Step indicator / progress bar for wizard mode
+ * Internal component used by DynamicForm
+ */
+
+import type { JSX } from 'react';
+import type { StepConfig } from '../../models/StepConfig';
+import { useI18n } from '../../hooks/useI18n';
+
+/**
+ * Props for FormStepper
+ */
+type Props = {
+ /** All step configurations */
+ steps: StepConfig[];
+ /** Current active step index (0-based) */
+ currentStep: number;
+ /** Optional click handler for step navigation */
+ onStepClick?: (stepIndex: number) => void;
+};
+
+type StepState = {
+ isActive: boolean;
+ isCompleted: boolean;
+ isClickable: boolean;
+};
+
+function getStepState(
+ index: number,
+ currentStep: number,
+ onStepClick?: (stepIndex: number) => void,
+): StepState {
+ const isActive = index === currentStep;
+ const isCompleted = index < currentStep;
+ return {
+ isActive,
+ isCompleted,
+ isClickable: Boolean(onStepClick) && isCompleted,
+ };
+}
+
+function getStepItemClassName(state: StepState): string {
+ return [
+ 'formkit-stepper-step flex items-center gap-2',
+ state.isActive ? 'formkit-stepper-step-active text-blue-600' : '',
+ state.isCompleted ? 'formkit-stepper-step-completed text-green-600' : '',
+ !state.isActive && !state.isCompleted ? 'text-gray-400' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+}
+
+function getStepNumberClassName(state: StepState): string {
+ return [
+ 'formkit-stepper-number flex items-center justify-center w-8 h-8 rounded-full border-2',
+ state.isActive ? 'border-blue-600 bg-blue-600 text-white' : '',
+ state.isCompleted ? 'border-green-600 bg-green-600 text-white' : '',
+ !state.isActive && !state.isCompleted ? 'border-gray-300 bg-white text-gray-400' : '',
+ state.isClickable ? 'cursor-pointer hover:opacity-80' : 'cursor-default',
+ ]
+ .filter(Boolean)
+ .join(' ');
+}
+
+function getStepTitleClassName(state: StepState): string {
+ return [
+ 'formkit-stepper-title text-sm font-medium',
+ state.isActive ? 'text-blue-600' : '',
+ state.isCompleted ? 'text-green-600' : '',
+ !state.isActive && !state.isCompleted ? 'text-gray-500' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+}
+
+function getStepAriaLabel(
+ index: number,
+ title: string,
+ state: StepState,
+ t: (key: string) => string,
+): string {
+ const stateSuffix = [
+ state.isCompleted ? `(${t('a11y.stepCompleted')})` : '',
+ state.isActive ? `(${t('a11y.stepCurrent')})` : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return `${t('a11y.stepNumber')} ${index + 1}: ${title}${stateSuffix ? ` ${stateSuffix}` : ''}`;
+}
+
+/**
+ * Displays step progress indicator for multi-step forms
+ * Supports accessible navigation with aria attributes
+ *
+ * @internal
+ */
+export default function FormStepper({
+ steps,
+ currentStep,
+ onStepClick,
+}: Readonly): JSX.Element {
+ const { t } = useI18n();
+
+ return (
+
+ );
+}
+
+export type { Props as FormStepperProps };
diff --git a/src/components/form/__tests__/DynamicForm.test.tsx b/src/components/form/__tests__/DynamicForm.test.tsx
new file mode 100644
index 0000000..12b015a
--- /dev/null
+++ b/src/components/form/__tests__/DynamicForm.test.tsx
@@ -0,0 +1,430 @@
+/**
+ * Integration tests for DynamicForm component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { z } from 'zod';
+import DynamicForm from '../DynamicForm';
+import { FieldType } from '../../../core/types';
+import type { FieldConfig } from '../../../models/FieldConfig';
+
+describe('DynamicForm', () => {
+ describe('basic rendering', () => {
+ it('renders form with fields', () => {
+ const fields: FieldConfig[] = [
+ { key: 'name', label: 'Name', type: FieldType.TEXT },
+ { key: 'email', label: 'Email', type: FieldType.EMAIL },
+ ];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('Name')).toBeInTheDocument();
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
+ });
+
+ it('renders submit button with custom label', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+ });
+
+ it('renders reset button when resetLabel is provided', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
+ });
+ });
+
+ describe('field types', () => {
+ it('renders TextField for TEXT type', () => {
+ const fields: FieldConfig[] = [{ key: 'name', label: 'Name', type: FieldType.TEXT }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('type', 'text');
+ });
+
+ it('renders TextField for EMAIL type', () => {
+ const fields: FieldConfig[] = [{ key: 'email', label: 'Email', type: FieldType.EMAIL }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('textbox', { name: 'Email' })).toHaveAttribute('type', 'email');
+ });
+
+ it('renders SelectField for SELECT type', async () => {
+ const user = userEvent.setup();
+ const fields: FieldConfig[] = [
+ {
+ key: 'role',
+ label: 'Role',
+ type: FieldType.SELECT,
+ options: [
+ { value: 'admin', label: 'Admin' },
+ { value: 'user', label: 'User' },
+ ],
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const combobox = screen.getByRole('combobox');
+ expect(combobox).toBeInTheDocument();
+
+ // Click to open dropdown and verify options
+ await user.click(combobox);
+ expect(screen.getByRole('option', { name: 'Admin' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'User' })).toBeInTheDocument();
+ });
+
+ it('renders CheckboxField for CHECKBOX type', () => {
+ const fields: FieldConfig[] = [{ key: 'agree', label: 'I agree', type: FieldType.CHECKBOX }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('checkbox', { name: 'I agree' })).toBeInTheDocument();
+ });
+
+ it('renders TextareaField for TEXTAREA type', () => {
+ const fields: FieldConfig[] = [{ key: 'bio', label: 'Bio', type: FieldType.TEXTAREA }];
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('textbox', { name: 'Bio' })).toBeInTheDocument();
+ });
+ });
+
+ describe('form submission', () => {
+ it('calls onSubmit with form values', async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ const fields: FieldConfig[] = [{ key: 'name', label: 'Name', type: FieldType.TEXT }];
+
+ render(
+ ,
+ );
+
+ await user.type(screen.getByLabelText('Name'), 'John Doe');
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ name: 'John Doe' });
+ });
+ });
+
+ it('shows validation errors on invalid submission', async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ const schema = z.object({
+ name: z.string().min(2, 'Name must be at least 2 characters'),
+ });
+
+ const fields: FieldConfig[] = [{ key: 'name', label: 'Name', type: FieldType.TEXT }];
+
+ render(
+ ,
+ );
+
+ // Submit empty form
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert')).toHaveTextContent('Name must be at least 2 characters');
+ });
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('field interactions', () => {
+ it('updates value on input change', async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText('Name');
+ await user.type(input, 'Hello');
+
+ expect(input).toHaveValue('Hello');
+ });
+
+ it('calls onChange when values change', async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.type(screen.getByLabelText('Name'), 'A');
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('reset functionality', () => {
+ it('resets form to default values', async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText('Name');
+ await user.clear(input);
+ await user.type(input, 'Changed');
+
+ expect(input).toHaveValue('Changed');
+
+ await user.click(screen.getByRole('button', { name: 'Reset' }));
+
+ expect(input).toHaveValue('Initial');
+ });
+ });
+
+ describe('conditional fields', () => {
+ it('shows field based on showWhen condition', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [
+ {
+ key: 'role',
+ label: 'Role',
+ type: FieldType.SELECT,
+ options: [
+ { value: 'user', label: 'User' },
+ { value: 'admin', label: 'Admin' },
+ ],
+ },
+ {
+ key: 'permissions',
+ label: 'Permissions',
+ type: FieldType.TEXT,
+ showWhen: { field: 'role', operator: 'equals', value: 'admin' },
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ // Permissions should be hidden initially
+ expect(screen.queryByLabelText('Permissions')).not.toBeInTheDocument();
+
+ // Select admin role via custom dropdown
+ const combobox = screen.getByRole('combobox');
+ await user.click(combobox);
+ await user.click(screen.getByRole('option', { name: 'Admin' }));
+
+ // Permissions should now be visible
+ expect(screen.getByLabelText('Permissions')).toBeInTheDocument();
+ });
+
+ it('hides field based on hideWhen condition', async () => {
+ const user = userEvent.setup();
+
+ const fields: FieldConfig[] = [
+ { key: 'showExtra', label: 'Show Extra', type: FieldType.CHECKBOX },
+ {
+ key: 'extra',
+ label: 'Extra Field',
+ type: FieldType.TEXT,
+ hideWhen: { field: 'showExtra', operator: 'equals', value: false },
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ // Extra should be hidden when checkbox is unchecked
+ expect(screen.queryByLabelText('Extra Field')).not.toBeInTheDocument();
+
+ // Check the checkbox
+ await user.click(screen.getByLabelText('Show Extra'));
+
+ // Extra should now be visible
+ expect(screen.getByLabelText('Extra Field')).toBeInTheDocument();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('associates labels with inputs', () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText('Name');
+ expect(input).toHaveAttribute('id', 'field-name');
+ });
+
+ it('sets aria-invalid on fields with errors', async () => {
+ const user = userEvent.setup();
+
+ const schema = z.object({ email: z.string().email('Invalid email') });
+
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText('Email');
+
+ // Trigger blur to mark as touched
+ await user.type(input, 'invalid');
+ await user.tab();
+
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(input).toHaveAttribute('aria-invalid', 'true');
+ });
+ });
+
+ it('shows required indicator for required fields', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('*')).toBeInTheDocument();
+ });
+ });
+
+ describe('no schema mode', () => {
+ it('works without schema (no validation)', async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/src/components/form/index.ts b/src/components/form/index.ts
new file mode 100644
index 0000000..d5d0aef
--- /dev/null
+++ b/src/components/form/index.ts
@@ -0,0 +1,7 @@
+/**
+ * Form components module exports
+ */
+
+export { default as DynamicForm, type DynamicFormProps } from './DynamicForm';
+export { default as DynamicFormStep, type DynamicFormStepProps } from './DynamicFormStep';
+export { default as FormStepper, type FormStepperProps } from './FormStepper';
diff --git a/src/components/index.ts b/src/components/index.ts
index 52f8fa8..919c4ba 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,4 +1,16 @@
-// Example placeholder export β replace with real components later.
-export const __components_placeholder = true;
+/**
+ * Components module - exports all public components
+ * Following CHM architecture: Components handle rendering only
+ */
-export * from './NoopButton';
+// Form components
+export * from './form';
+
+// Field components (internal - used by DynamicForm)
+export * from './fields';
+
+// Layout components (internal - used by field components)
+export * from './layout';
+
+// Context (internal use only)
+export { default as FormKitProvider, useFormKitContext } from './context/FormKitContext';
diff --git a/src/components/layout/FieldError.tsx b/src/components/layout/FieldError.tsx
new file mode 100644
index 0000000..ada0a0c
--- /dev/null
+++ b/src/components/layout/FieldError.tsx
@@ -0,0 +1,39 @@
+/**
+ * FieldError - Standardized error message display
+ */
+
+import type { JSX } from 'react';
+
+/**
+ * Props for FieldError
+ */
+type Props = {
+ /** Error element ID (for aria-describedby) */
+ id: string;
+ /** Error message to display */
+ message: string | null;
+ /** Custom CSS class */
+ className?: string;
+};
+
+/**
+ * FieldError component for displaying field validation errors
+ * Uses role="alert" for live announcement to screen readers
+ */
+export default function FieldError({ id, message, className = '' }: Props): JSX.Element | null {
+ if (!message) {
+ return null;
+ }
+
+ return (
+
+ {message}
+
+ );
+}
+
+export type { Props as FieldErrorProps };
diff --git a/src/components/layout/FieldGroup.tsx b/src/components/layout/FieldGroup.tsx
new file mode 100644
index 0000000..c2bdd64
--- /dev/null
+++ b/src/components/layout/FieldGroup.tsx
@@ -0,0 +1,48 @@
+/**
+ * FieldGroup - Visual grouping for related fields
+ */
+
+import type { JSX, ReactNode } from 'react';
+
+/**
+ * Props for FieldGroup
+ */
+type Props = {
+ /** Group title/legend */
+ title?: string;
+ /** Group description */
+ description?: string;
+ /** Child fields */
+ children: ReactNode;
+ /** Custom CSS class */
+ className?: string;
+};
+
+/**
+ * FieldGroup component for visually grouping related fields
+ * Uses fieldset/legend for proper accessibility
+ */
+export default function FieldGroup({
+ title,
+ description,
+ children,
+ className = '',
+}: Props): JSX.Element {
+ return (
+
+ );
+}
+
+export type { Props as FieldGroupProps };
diff --git a/src/components/layout/FieldLabel.tsx b/src/components/layout/FieldLabel.tsx
new file mode 100644
index 0000000..a4edb8a
--- /dev/null
+++ b/src/components/layout/FieldLabel.tsx
@@ -0,0 +1,63 @@
+/**
+ * FieldLabel - Standardized field label component
+ */
+
+import type { JSX } from 'react';
+import { useI18n } from '../../hooks/useI18n';
+
+/**
+ * Props for FieldLabel
+ */
+type Props = {
+ /** The label text */
+ label: string;
+ /** HTML for attribute (links to field id) */
+ htmlFor?: string;
+ /** Whether the field is required (shows asterisk) */
+ required?: boolean;
+ /** Render as different element (default: label) */
+ as?: 'label' | 'legend';
+ /** Custom CSS class */
+ className?: string;
+};
+
+/**
+ * FieldLabel component for form field labels
+ * Shows required indicator (asterisk) when needed
+ */
+export default function FieldLabel({
+ label,
+ htmlFor,
+ required,
+ as = 'label',
+ className = '',
+}: Props): JSX.Element {
+ const { t } = useI18n();
+ const classes = `formkit-field-label text-sm font-medium text-gray-700 ${className}`.trim();
+
+ if (as === 'legend') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export type { Props as FieldLabelProps };
diff --git a/src/components/layout/FormActions.tsx b/src/components/layout/FormActions.tsx
new file mode 100644
index 0000000..e3db39d
--- /dev/null
+++ b/src/components/layout/FormActions.tsx
@@ -0,0 +1,135 @@
+/**
+ * FormActions - Submit and Reset buttons with loading state
+ */
+
+import type { JSX } from 'react';
+import { useI18n } from '../../hooks/useI18n';
+
+/**
+ * Props for FormActions
+ */
+type Props = {
+ /** Submit button label */
+ submitLabel?: string;
+ /** Reset button label (omit to hide) */
+ resetLabel?: string;
+ /** Previous step button label (wizard mode) */
+ prevLabel?: string;
+ /** Whether form is currently submitting */
+ isSubmitting?: boolean;
+ /** Reset button click handler */
+ onReset?: () => void;
+ /** Previous button click handler (wizard mode) */
+ onPrev?: () => void;
+ /** Custom CSS class */
+ className?: string;
+};
+
+/**
+ * FormActions component for form submit/reset buttons
+ * Handles loading state and wizard navigation
+ */
+export default function FormActions({
+ submitLabel,
+ resetLabel,
+ prevLabel,
+ isSubmitting = false,
+ onReset,
+ onPrev,
+ className = '',
+}: Props): JSX.Element {
+ const { t } = useI18n();
+ const finalSubmitLabel = submitLabel ?? t('form.submit');
+
+ return (
+
+ {/* Previous button (wizard mode) */}
+ {prevLabel && onPrev && (
+
+ )}
+
+ {/* Spacer to push submit to the right when prev exists */}
+ {prevLabel && onPrev &&
}
+
+ {/* Reset button */}
+ {resetLabel && onReset && (
+
+ )}
+
+ {/* Submit button */}
+
+
+ );
+}
+
+export type { Props as FormActionsProps };
diff --git a/src/components/layout/FormSection.tsx b/src/components/layout/FormSection.tsx
new file mode 100644
index 0000000..3e3efd1
--- /dev/null
+++ b/src/components/layout/FormSection.tsx
@@ -0,0 +1,83 @@
+/**
+ * FormSection - Renders a section of grouped fields with its own grid layout
+ */
+
+import type { JSX } from 'react';
+import type { SectionConfig } from '../../models/SectionConfig';
+import { getGridContainerClass, getColSpanClass } from '../../core/grid';
+import Field from '../fields/Field';
+
+/**
+ * Props for FormSection
+ */
+type Props = {
+ /** Section configuration */
+ config: SectionConfig;
+};
+
+/**
+ * FormSection component for rendering a group of fields with independent layout
+ *
+ * Features:
+ * - Accessible fieldset/legend structure
+ * - Independent grid configuration per section
+ * - Responsive columns support
+ * - Optional title and description
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export default function FormSection({ config }: Readonly): JSX.Element {
+ const { title, description, columns = 1, gap = 4, fields, className = '', bordered } = config;
+
+ // Determine if we should show a border (default true when title present)
+ const showBorder = bordered ?? !!title;
+
+ // Build container classes
+ const containerClasses = [
+ 'formkit-form-section',
+ showBorder ? 'border border-gray-200 rounded-lg p-4' : '',
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ // Build grid classes
+ const gridClasses = getGridContainerClass(columns, gap);
+
+ return (
+
+ );
+}
+
+export type { Props as FormSectionProps };
diff --git a/src/components/layout/__tests__/FormActions.test.tsx b/src/components/layout/__tests__/FormActions.test.tsx
new file mode 100644
index 0000000..c978987
--- /dev/null
+++ b/src/components/layout/__tests__/FormActions.test.tsx
@@ -0,0 +1,69 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import FormActions from '../FormActions';
+import I18nProvider from '../../context/I18nContext';
+
+describe('FormActions', () => {
+ const renderActions = (props: Partial> = {}) =>
+ render(
+
+
+ ,
+ );
+
+ it('renders default submit label from i18n', () => {
+ renderActions();
+ expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
+ });
+
+ it('renders custom submit label', () => {
+ renderActions({ submitLabel: 'Save Changes' });
+ expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument();
+ });
+
+ it('renders previous and reset buttons when handlers are provided', async () => {
+ const user = userEvent.setup();
+ const onPrev = vi.fn();
+ const onReset = vi.fn();
+
+ renderActions({
+ prevLabel: 'Back',
+ onPrev,
+ resetLabel: 'Reset',
+ onReset,
+ });
+
+ await user.click(screen.getByRole('button', { name: 'Back' }));
+ await user.click(screen.getByRole('button', { name: 'Reset' }));
+
+ expect(onPrev).toHaveBeenCalledTimes(1);
+ expect(onReset).toHaveBeenCalledTimes(1);
+ });
+
+ it('hides previous and reset when labels exist but handlers are missing', () => {
+ renderActions({ prevLabel: 'Back', resetLabel: 'Reset' });
+
+ expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Reset' })).not.toBeInTheDocument();
+ });
+
+ it('shows submitting state and disables buttons', () => {
+ const onPrev = vi.fn();
+ const onReset = vi.fn();
+
+ renderActions({
+ prevLabel: 'Back',
+ onPrev,
+ resetLabel: 'Reset',
+ onReset,
+ isSubmitting: true,
+ });
+
+ expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Submitting...' })).toBeDisabled();
+ });
+});
diff --git a/src/components/layout/__tests__/FormSection.test.tsx b/src/components/layout/__tests__/FormSection.test.tsx
new file mode 100644
index 0000000..281935e
--- /dev/null
+++ b/src/components/layout/__tests__/FormSection.test.tsx
@@ -0,0 +1,189 @@
+/**
+ * Tests for FormSection component
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import FormSection from '../FormSection';
+import { FormKitContext } from '../../context/FormKitContext';
+import type { FormContextValue } from '../../../models/FormState';
+import { FieldType } from '../../../core/types';
+import type { SectionConfig } from '../../../models/SectionConfig';
+
+// Mock form context
+const createMockContext = (overrides: Partial = {}): FormContextValue => ({
+ getValue: vi.fn(() => ''),
+ setValue: vi.fn(),
+ getError: vi.fn(() => null),
+ setError: vi.fn(),
+ getTouched: vi.fn(() => false),
+ setTouched: vi.fn(),
+ getValues: vi.fn(() => ({})),
+ isSubmitting: false,
+ isValid: true,
+ ...overrides,
+});
+
+const renderWithContext = (
+ ui: React.ReactElement,
+ context: FormContextValue = createMockContext(),
+) => {
+ return render({ui});
+};
+
+describe('FormSection', () => {
+ it('renders section with title', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ title: 'Personal Information',
+ fields: [{ key: 'name', label: 'Name', type: FieldType.TEXT }],
+ };
+
+ renderWithContext();
+
+ expect(screen.getByText('Personal Information')).toBeInTheDocument();
+ });
+
+ it('renders section with description', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ title: 'Contact',
+ description: 'Enter your contact details',
+ fields: [{ key: 'email', label: 'Email', type: FieldType.EMAIL }],
+ };
+
+ renderWithContext();
+
+ expect(screen.getByText('Enter your contact details')).toBeInTheDocument();
+ });
+
+ it('renders fields inside section', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ fields: [
+ { key: 'firstName', label: 'First Name', type: FieldType.TEXT },
+ { key: 'lastName', label: 'Last Name', type: FieldType.TEXT },
+ ],
+ };
+
+ renderWithContext();
+
+ expect(screen.getByText('First Name')).toBeInTheDocument();
+ expect(screen.getByText('Last Name')).toBeInTheDocument();
+ });
+
+ it('applies border when title is present', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ title: 'With Border',
+ fields: [{ key: 'field1', label: 'Field 1', type: FieldType.TEXT }],
+ };
+
+ const { container } = renderWithContext();
+ const fieldset = container.querySelector('fieldset');
+
+ expect(fieldset?.className).toContain('border');
+ });
+
+ it('does not apply border when no title and bordered=false', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ bordered: false,
+ fields: [{ key: 'field1', label: 'Field 1', type: FieldType.TEXT }],
+ };
+
+ const { container } = renderWithContext();
+ const fieldset = container.querySelector('fieldset');
+
+ expect(fieldset?.className).not.toContain('border-gray-200');
+ });
+
+ it('applies custom className', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ className: 'custom-section-class',
+ fields: [{ key: 'field1', label: 'Field 1', type: FieldType.TEXT }],
+ };
+
+ const { container } = renderWithContext();
+ const fieldset = container.querySelector('fieldset');
+
+ expect(fieldset?.className).toContain('custom-section-class');
+ });
+
+ it('renders with grid classes for columns', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ columns: 2,
+ fields: [
+ { key: 'field1', label: 'Field 1', type: FieldType.TEXT },
+ { key: 'field2', label: 'Field 2', type: FieldType.TEXT },
+ ],
+ };
+
+ const { container } = renderWithContext();
+ const grid = container.querySelector('.grid');
+
+ expect(grid?.className).toContain('grid-cols-2');
+ });
+
+ it('renders with responsive grid classes', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ columns: { default: 1, md: 2, lg: 3 },
+ fields: [{ key: 'field1', label: 'Field 1', type: FieldType.TEXT }],
+ };
+
+ const { container } = renderWithContext();
+ const grid = container.querySelector('.grid');
+
+ expect(grid?.className).toContain('grid-cols-1');
+ expect(grid?.className).toContain('md:grid-cols-2');
+ expect(grid?.className).toContain('lg:grid-cols-3');
+ });
+
+ it('applies colSpan to individual fields', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ columns: 2,
+ fields: [
+ { key: 'field1', label: 'Field 1', type: FieldType.TEXT, colSpan: 1 },
+ { key: 'field2', label: 'Field 2', type: FieldType.TEXT, colSpan: 2 },
+ ],
+ };
+
+ const { container } = renderWithContext();
+ const grid = container.querySelector('.grid');
+ const children = grid?.children;
+
+ expect(children?.[0]?.className).toContain('col-span-1');
+ expect(children?.[1]?.className).toContain('col-span-2');
+ });
+
+ it('uses fieldset and legend for accessibility', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ title: 'Accessible Section',
+ fields: [{ key: 'field1', label: 'Field 1', type: FieldType.TEXT }],
+ };
+
+ const { container } = renderWithContext();
+
+ expect(container.querySelector('fieldset')).toBeInTheDocument();
+ expect(container.querySelector('legend')).toBeInTheDocument();
+ expect(container.querySelector('legend')?.textContent).toBe('Accessible Section');
+ });
+
+ it('applies custom gap', () => {
+ const config: SectionConfig = {
+ type: 'section',
+ gap: 6,
+ fields: [{ key: 'field1', label: 'Field 1', type: FieldType.TEXT }],
+ };
+
+ const { container } = renderWithContext();
+ const grid = container.querySelector('.grid');
+
+ expect(grid?.className).toContain('gap-6');
+ });
+});
diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts
new file mode 100644
index 0000000..b18f57b
--- /dev/null
+++ b/src/components/layout/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Layout components module exports
+ */
+
+export { default as FieldLabel, type FieldLabelProps } from './FieldLabel';
+export { default as FieldError, type FieldErrorProps } from './FieldError';
+export { default as FieldGroup, type FieldGroupProps } from './FieldGroup';
+export { default as FormActions, type FormActionsProps } from './FormActions';
+export { default as FormSection, type FormSectionProps } from './FormSection';
diff --git a/src/core/__tests__/conditional.test.ts b/src/core/__tests__/conditional.test.ts
new file mode 100644
index 0000000..6e62de1
--- /dev/null
+++ b/src/core/__tests__/conditional.test.ts
@@ -0,0 +1,186 @@
+/**
+ * Tests for core/conditional.ts
+ */
+
+import { describe, it, expect } from 'vitest';
+import { evaluateRule, isFieldVisible } from '../conditional';
+import type { ConditionalRule } from '../types';
+
+describe('evaluateRule', () => {
+ describe('equals operator', () => {
+ it('returns true when values match', () => {
+ const rule: ConditionalRule = { field: 'role', operator: 'equals', value: 'admin' };
+ expect(evaluateRule(rule, { role: 'admin' })).toBe(true);
+ });
+
+ it('returns false when values differ', () => {
+ const rule: ConditionalRule = { field: 'role', operator: 'equals', value: 'admin' };
+ expect(evaluateRule(rule, { role: 'user' })).toBe(false);
+ });
+
+ it('returns false when field is missing', () => {
+ const rule: ConditionalRule = { field: 'role', operator: 'equals', value: 'admin' };
+ expect(evaluateRule(rule, {})).toBe(false);
+ });
+ });
+
+ describe('not_equals operator', () => {
+ it('returns true when values differ', () => {
+ const rule: ConditionalRule = { field: 'type', operator: 'not_equals', value: 'free' };
+ expect(evaluateRule(rule, { type: 'premium' })).toBe(true);
+ });
+
+ it('returns false when values match', () => {
+ const rule: ConditionalRule = { field: 'type', operator: 'not_equals', value: 'free' };
+ expect(evaluateRule(rule, { type: 'free' })).toBe(false);
+ });
+ });
+
+ describe('is_empty operator', () => {
+ it('returns true for null', () => {
+ const rule: ConditionalRule = { field: 'name', operator: 'is_empty' };
+ expect(evaluateRule(rule, { name: null })).toBe(true);
+ });
+
+ it('returns true for undefined', () => {
+ const rule: ConditionalRule = { field: 'name', operator: 'is_empty' };
+ expect(evaluateRule(rule, {})).toBe(true);
+ });
+
+ it('returns true for empty string', () => {
+ const rule: ConditionalRule = { field: 'name', operator: 'is_empty' };
+ expect(evaluateRule(rule, { name: '' })).toBe(true);
+ });
+
+ it('returns false for non-empty value', () => {
+ const rule: ConditionalRule = { field: 'name', operator: 'is_empty' };
+ expect(evaluateRule(rule, { name: 'John' })).toBe(false);
+ });
+ });
+
+ describe('is_not_empty operator', () => {
+ it('returns true for non-empty value', () => {
+ const rule: ConditionalRule = { field: 'email', operator: 'is_not_empty' };
+ expect(evaluateRule(rule, { email: 'test@test.com' })).toBe(true);
+ });
+
+ it('returns false for empty string', () => {
+ const rule: ConditionalRule = { field: 'email', operator: 'is_not_empty' };
+ expect(evaluateRule(rule, { email: '' })).toBe(false);
+ });
+
+ it('returns false for null', () => {
+ const rule: ConditionalRule = { field: 'email', operator: 'is_not_empty' };
+ expect(evaluateRule(rule, { email: null })).toBe(false);
+ });
+ });
+
+ describe('gt operator', () => {
+ it('returns true when field value is greater', () => {
+ const rule: ConditionalRule = { field: 'age', operator: 'gt', value: 18 };
+ expect(evaluateRule(rule, { age: 21 })).toBe(true);
+ });
+
+ it('returns false when field value is equal', () => {
+ const rule: ConditionalRule = { field: 'age', operator: 'gt', value: 18 };
+ expect(evaluateRule(rule, { age: 18 })).toBe(false);
+ });
+
+ it('returns false when field value is less', () => {
+ const rule: ConditionalRule = { field: 'age', operator: 'gt', value: 18 };
+ expect(evaluateRule(rule, { age: 16 })).toBe(false);
+ });
+ });
+
+ describe('lt operator', () => {
+ it('returns true when field value is less', () => {
+ const rule: ConditionalRule = { field: 'quantity', operator: 'lt', value: 10 };
+ expect(evaluateRule(rule, { quantity: 5 })).toBe(true);
+ });
+
+ it('returns false when field value is equal', () => {
+ const rule: ConditionalRule = { field: 'quantity', operator: 'lt', value: 10 };
+ expect(evaluateRule(rule, { quantity: 10 })).toBe(false);
+ });
+
+ it('returns false when field value is greater', () => {
+ const rule: ConditionalRule = { field: 'quantity', operator: 'lt', value: 10 };
+ expect(evaluateRule(rule, { quantity: 15 })).toBe(false);
+ });
+ });
+
+ describe('contains operator', () => {
+ it('returns true when value contains substring', () => {
+ const rule: ConditionalRule = { field: 'email', operator: 'contains', value: '@gmail' };
+ expect(evaluateRule(rule, { email: 'test@gmail.com' })).toBe(true);
+ });
+
+ it('returns false when value does not contain substring', () => {
+ const rule: ConditionalRule = { field: 'email', operator: 'contains', value: '@gmail' };
+ expect(evaluateRule(rule, { email: 'test@yahoo.com' })).toBe(false);
+ });
+ });
+
+ describe('unknown operator', () => {
+ it('returns false for unknown operator', () => {
+ const rule = { field: 'x', operator: 'unknown' as 'equals', value: 'y' };
+ expect(evaluateRule(rule, { x: 'y' })).toBe(false);
+ });
+ });
+});
+
+describe('isFieldVisible', () => {
+ const values = { role: 'admin', status: 'active' };
+
+ describe('no conditions', () => {
+ it('returns true when no showWhen or hideWhen', () => {
+ expect(isFieldVisible(undefined, undefined, values)).toBe(true);
+ });
+ });
+
+ describe('showWhen only', () => {
+ it('returns true when showWhen condition is met', () => {
+ const showWhen: ConditionalRule = { field: 'role', operator: 'equals', value: 'admin' };
+ expect(isFieldVisible(showWhen, undefined, values)).toBe(true);
+ });
+
+ it('returns false when showWhen condition is not met', () => {
+ const showWhen: ConditionalRule = { field: 'role', operator: 'equals', value: 'user' };
+ expect(isFieldVisible(showWhen, undefined, values)).toBe(false);
+ });
+ });
+
+ describe('hideWhen only', () => {
+ it('returns false when hideWhen condition is met', () => {
+ const hideWhen: ConditionalRule = { field: 'status', operator: 'equals', value: 'active' };
+ expect(isFieldVisible(undefined, hideWhen, values)).toBe(false);
+ });
+
+ it('returns true when hideWhen condition is not met', () => {
+ const hideWhen: ConditionalRule = { field: 'status', operator: 'equals', value: 'inactive' };
+ expect(isFieldVisible(undefined, hideWhen, values)).toBe(true);
+ });
+ });
+
+ describe('both showWhen and hideWhen', () => {
+ it('hideWhen takes precedence - hides when hideWhen is true', () => {
+ const showWhen: ConditionalRule = { field: 'role', operator: 'equals', value: 'admin' };
+ const hideWhen: ConditionalRule = { field: 'status', operator: 'equals', value: 'active' };
+ // showWhen is true, but hideWhen is also true - should hide
+ expect(isFieldVisible(showWhen, hideWhen, values)).toBe(false);
+ });
+
+ it('shows field when hideWhen is false and showWhen is true', () => {
+ const showWhen: ConditionalRule = { field: 'role', operator: 'equals', value: 'admin' };
+ const hideWhen: ConditionalRule = { field: 'status', operator: 'equals', value: 'inactive' };
+ expect(isFieldVisible(showWhen, hideWhen, values)).toBe(true);
+ });
+
+ it('hides field when hideWhen is false but showWhen is also false', () => {
+ const showWhen: ConditionalRule = { field: 'role', operator: 'equals', value: 'user' };
+ const hideWhen: ConditionalRule = { field: 'status', operator: 'equals', value: 'inactive' };
+ // hideWhen is false, but showWhen is also false - should hide
+ expect(isFieldVisible(showWhen, hideWhen, values)).toBe(false);
+ });
+ });
+});
diff --git a/src/core/__tests__/grid.test.ts b/src/core/__tests__/grid.test.ts
new file mode 100644
index 0000000..d98d32d
--- /dev/null
+++ b/src/core/__tests__/grid.test.ts
@@ -0,0 +1,116 @@
+/**
+ * Tests for grid utilities
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ getGridColumnsClass,
+ getColSpanClass,
+ getGapClass,
+ getGridContainerClass,
+ normalizeColumnCount,
+} from '../grid';
+
+describe('grid utilities', () => {
+ describe('getGridColumnsClass', () => {
+ it('returns simple column class for number', () => {
+ expect(getGridColumnsClass(1)).toBe('grid-cols-1');
+ expect(getGridColumnsClass(3)).toBe('grid-cols-3');
+ expect(getGridColumnsClass(12)).toBe('grid-cols-12');
+ });
+
+ it('returns responsive classes for object config', () => {
+ expect(getGridColumnsClass({ default: 1, md: 2 })).toBe('grid-cols-1 md:grid-cols-2');
+ expect(getGridColumnsClass({ default: 1, sm: 2, lg: 3 })).toBe(
+ 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
+ );
+ expect(getGridColumnsClass({ default: 1, sm: 2, md: 3, lg: 4, xl: 6 })).toBe(
+ 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6',
+ );
+ });
+
+ it('handles partial responsive config', () => {
+ expect(getGridColumnsClass({ md: 2 })).toBe('md:grid-cols-2');
+ expect(getGridColumnsClass({ lg: 4 })).toBe('lg:grid-cols-4');
+ });
+
+ it('defaults to 1 column', () => {
+ expect(getGridColumnsClass()).toBe('grid-cols-1');
+ });
+ });
+
+ describe('getColSpanClass', () => {
+ it('returns simple col-span class for number', () => {
+ expect(getColSpanClass(1)).toBe('col-span-1');
+ expect(getColSpanClass(6)).toBe('col-span-6');
+ expect(getColSpanClass(12)).toBe('col-span-12');
+ });
+
+ it('returns responsive classes for object config', () => {
+ expect(getColSpanClass({ default: 12, md: 6 })).toBe('col-span-12 md:col-span-6');
+ expect(getColSpanClass({ default: 12, sm: 6, lg: 4 })).toBe(
+ 'col-span-12 sm:col-span-6 lg:col-span-4',
+ );
+ });
+
+ it('adds default span when not specified in responsive config', () => {
+ expect(getColSpanClass({ md: 6 })).toBe('col-span-1 md:col-span-6');
+ expect(getColSpanClass({ md: 6 }, 2)).toBe('col-span-2 md:col-span-6');
+ });
+
+ it('uses default span when undefined', () => {
+ expect(getColSpanClass()).toBe('col-span-1');
+ });
+ });
+
+ describe('getGapClass', () => {
+ it('returns gap class', () => {
+ expect(getGapClass(2)).toBe('gap-2');
+ expect(getGapClass(4)).toBe('gap-4');
+ expect(getGapClass(8)).toBe('gap-8');
+ });
+
+ it('defaults to gap-4', () => {
+ expect(getGapClass()).toBe('gap-4');
+ });
+ });
+
+ describe('getGridContainerClass', () => {
+ it('combines grid, columns, and gap classes', () => {
+ expect(getGridContainerClass(2, 4)).toBe('grid grid-cols-2 gap-4');
+ expect(getGridContainerClass(3, 6)).toBe('grid grid-cols-3 gap-6');
+ });
+
+ it('works with responsive columns', () => {
+ expect(getGridContainerClass({ default: 1, md: 2 }, 4)).toBe(
+ 'grid grid-cols-1 md:grid-cols-2 gap-4',
+ );
+ });
+
+ it('uses defaults', () => {
+ expect(getGridContainerClass()).toBe('grid grid-cols-1 gap-4');
+ });
+ });
+
+ describe('normalizeColumnCount', () => {
+ it('returns number as-is', () => {
+ expect(normalizeColumnCount(2)).toBe(2);
+ expect(normalizeColumnCount(6)).toBe(6);
+ });
+
+ it('extracts default from responsive config', () => {
+ expect(normalizeColumnCount({ default: 2, md: 4 })).toBe(2);
+ expect(normalizeColumnCount({ default: 1, lg: 3 })).toBe(1);
+ });
+
+ it('returns fallback when no default in responsive config', () => {
+ expect(normalizeColumnCount({ md: 4 })).toBe(1);
+ expect(normalizeColumnCount({ md: 4 }, 2)).toBe(2);
+ });
+
+ it('returns fallback when undefined', () => {
+ expect(normalizeColumnCount(undefined)).toBe(1);
+ expect(normalizeColumnCount(undefined, 3)).toBe(3);
+ });
+ });
+});
diff --git a/src/core/__tests__/schema-helpers.test.ts b/src/core/__tests__/schema-helpers.test.ts
new file mode 100644
index 0000000..a5c643b
--- /dev/null
+++ b/src/core/__tests__/schema-helpers.test.ts
@@ -0,0 +1,212 @@
+/**
+ * Tests for core/schema-helpers.ts
+ */
+
+import { describe, it, expect } from 'vitest';
+import { z } from 'zod';
+import { createFieldSchema, mergeSchemas } from '../schema-helpers';
+import { FieldType } from '../types';
+
+describe('createFieldSchema', () => {
+ describe('FieldType.TEXT', () => {
+ it('creates string schema', () => {
+ const schema = createFieldSchema(FieldType.TEXT);
+ expect(schema.safeParse('hello').success).toBe(true);
+ });
+
+ it('applies minLength constraint', () => {
+ const schema = createFieldSchema(FieldType.TEXT, { required: true, minLength: 3 });
+ expect(schema.safeParse('ab').success).toBe(false);
+ expect(schema.safeParse('abc').success).toBe(true);
+ });
+
+ it('applies maxLength constraint', () => {
+ const schema = createFieldSchema(FieldType.TEXT, { required: true, maxLength: 5 });
+ expect(schema.safeParse('abcdef').success).toBe(false);
+ expect(schema.safeParse('abcde').success).toBe(true);
+ });
+
+ it('applies pattern constraint', () => {
+ const schema = createFieldSchema(FieldType.TEXT, {
+ required: true,
+ pattern: /^[a-z]+$/,
+ message: 'Only lowercase letters',
+ });
+ expect(schema.safeParse('ABC').success).toBe(false);
+ expect(schema.safeParse('abc').success).toBe(true);
+ });
+ });
+
+ describe('FieldType.EMAIL', () => {
+ it('creates email schema', () => {
+ const schema = createFieldSchema(FieldType.EMAIL, { required: true });
+ expect(schema.safeParse('invalid').success).toBe(false);
+ expect(schema.safeParse('test@example.com').success).toBe(true);
+ });
+
+ it('uses custom message', () => {
+ const schema = createFieldSchema(FieldType.EMAIL, {
+ required: true,
+ message: 'Custom email error',
+ });
+ const result = schema.safeParse('invalid');
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues[0].message).toBe('Custom email error');
+ }
+ });
+ });
+
+ describe('FieldType.PASSWORD', () => {
+ it('creates string schema for password', () => {
+ const schema = createFieldSchema(FieldType.PASSWORD, { required: true });
+ expect(schema.safeParse('secret123').success).toBe(true);
+ });
+ });
+
+ describe('FieldType.TEXTAREA', () => {
+ it('creates string schema for textarea', () => {
+ const schema = createFieldSchema(FieldType.TEXTAREA, { required: true });
+ expect(schema.safeParse('long text content').success).toBe(true);
+ });
+ });
+
+ describe('FieldType.NUMBER', () => {
+ it('creates coerced number schema', () => {
+ const schema = createFieldSchema(FieldType.NUMBER, { required: true });
+ expect(schema.safeParse('42').success).toBe(true);
+ expect(schema.safeParse(42).success).toBe(true);
+ });
+
+ it('applies min constraint', () => {
+ const schema = createFieldSchema(FieldType.NUMBER, { required: true, min: 0 });
+ expect(schema.safeParse(-1).success).toBe(false);
+ expect(schema.safeParse(0).success).toBe(true);
+ });
+
+ it('applies max constraint', () => {
+ const schema = createFieldSchema(FieldType.NUMBER, { required: true, max: 100 });
+ expect(schema.safeParse(101).success).toBe(false);
+ expect(schema.safeParse(100).success).toBe(true);
+ });
+ });
+
+ describe('FieldType.CHECKBOX', () => {
+ it('creates boolean schema', () => {
+ const schema = createFieldSchema(FieldType.CHECKBOX, { required: true });
+ expect(schema.safeParse(true).success).toBe(true);
+ expect(schema.safeParse(false).success).toBe(true);
+ expect(schema.safeParse('true').success).toBe(false);
+ });
+ });
+
+ describe('FieldType.SWITCH', () => {
+ it('creates boolean schema', () => {
+ const schema = createFieldSchema(FieldType.SWITCH, { required: true });
+ expect(schema.safeParse(true).success).toBe(true);
+ });
+ });
+
+ describe('FieldType.DATE', () => {
+ it('creates coerced date schema', () => {
+ const schema = createFieldSchema(FieldType.DATE, { required: true });
+ expect(schema.safeParse('2024-01-15').success).toBe(true);
+ expect(schema.safeParse(new Date()).success).toBe(true);
+ });
+ });
+
+ describe('FieldType.FILE', () => {
+ it('creates File instance schema', () => {
+ const schema = createFieldSchema(FieldType.FILE, { required: true });
+ const file = new File(['content'], 'test.txt', { type: 'text/plain' });
+ expect(schema.safeParse(file).success).toBe(true);
+ expect(schema.safeParse('not a file').success).toBe(false);
+ });
+ });
+
+ describe('FieldType.SELECT', () => {
+ it('creates string schema for select', () => {
+ const schema = createFieldSchema(FieldType.SELECT, { required: true });
+ expect(schema.safeParse('option1').success).toBe(true);
+ });
+ });
+
+ describe('FieldType.RADIO', () => {
+ it('creates string schema for radio', () => {
+ const schema = createFieldSchema(FieldType.RADIO, { required: true });
+ expect(schema.safeParse('option1').success).toBe(true);
+ });
+ });
+
+ describe('FieldType.MULTI_SELECT', () => {
+ it('creates array schema', () => {
+ const schema = createFieldSchema(FieldType.MULTI_SELECT, { required: true });
+ expect(schema.safeParse(['a', 'b']).success).toBe(true);
+ });
+
+ it('applies min constraint', () => {
+ const schema = createFieldSchema(FieldType.MULTI_SELECT, { required: true, min: 2 });
+ expect(schema.safeParse(['a']).success).toBe(false);
+ expect(schema.safeParse(['a', 'b']).success).toBe(true);
+ });
+
+ it('applies max constraint', () => {
+ const schema = createFieldSchema(FieldType.MULTI_SELECT, { required: true, max: 2 });
+ expect(schema.safeParse(['a', 'b', 'c']).success).toBe(false);
+ expect(schema.safeParse(['a', 'b']).success).toBe(true);
+ });
+ });
+
+ describe('FieldType.ARRAY', () => {
+ it('creates array schema', () => {
+ const schema = createFieldSchema(FieldType.ARRAY, { required: true });
+ expect(schema.safeParse([]).success).toBe(true);
+ });
+ });
+
+ describe('optional fields', () => {
+ it('makes field optional when required is false', () => {
+ const schema = createFieldSchema(FieldType.TEXT, { required: false });
+ expect(schema.safeParse(undefined).success).toBe(true);
+ });
+
+ it('makes field optional by default', () => {
+ const schema = createFieldSchema(FieldType.TEXT);
+ expect(schema.safeParse(undefined).success).toBe(true);
+ });
+ });
+});
+
+describe('mergeSchemas', () => {
+ it('merges two schemas', () => {
+ const schema1 = z.object({ name: z.string() });
+ const schema2 = z.object({ email: z.string().email() });
+
+ const merged = mergeSchemas([schema1, schema2]);
+
+ expect(merged.safeParse({ name: 'John', email: 'john@test.com' }).success).toBe(true);
+ expect(merged.safeParse({ name: 'John' }).success).toBe(false);
+ expect(merged.safeParse({ email: 'john@test.com' }).success).toBe(false);
+ });
+
+ it('merges multiple schemas', () => {
+ const schema1 = z.object({ a: z.string() });
+ const schema2 = z.object({ b: z.number() });
+ const schema3 = z.object({ c: z.boolean() });
+
+ const merged = mergeSchemas([schema1, schema2, schema3]);
+
+ expect(merged.safeParse({ a: 'test', b: 42, c: true }).success).toBe(true);
+ });
+
+ it('returns empty object schema for empty array', () => {
+ const merged = mergeSchemas([]);
+ expect(merged.safeParse({}).success).toBe(true);
+ });
+
+ it('handles single schema', () => {
+ const schema = z.object({ name: z.string() });
+ const merged = mergeSchemas([schema]);
+ expect(merged.safeParse({ name: 'John' }).success).toBe(true);
+ });
+});
diff --git a/src/core/__tests__/validator.test.ts b/src/core/__tests__/validator.test.ts
new file mode 100644
index 0000000..6c27520
--- /dev/null
+++ b/src/core/__tests__/validator.test.ts
@@ -0,0 +1,165 @@
+/**
+ * Tests for core/validator.ts
+ */
+
+import { describe, it, expect } from 'vitest';
+import { z } from 'zod';
+import { mapZodErrors, validateSync, validateField, isEmpty } from '../validator';
+
+describe('mapZodErrors', () => {
+ it('maps single field error', () => {
+ const schema = z.object({
+ email: z.string().email('Invalid email'),
+ });
+
+ const result = schema.safeParse({ email: 'invalid' });
+ if (!result.success) {
+ const errors = mapZodErrors(result.error);
+ expect(errors.email).toBe('Invalid email');
+ }
+ });
+
+ it('maps multiple field errors', () => {
+ const schema = z.object({
+ name: z.string().min(2, 'Name too short'),
+ email: z.string().email('Invalid email'),
+ });
+
+ const result = schema.safeParse({ name: 'A', email: 'invalid' });
+ if (!result.success) {
+ const errors = mapZodErrors(result.error);
+ expect(errors.name).toBe('Name too short');
+ expect(errors.email).toBe('Invalid email');
+ }
+ });
+
+ it('keeps only first error per field', () => {
+ const schema = z.object({
+ password: z.string().min(8, 'Min 8 chars').regex(/[A-Z]/, 'Needs uppercase'),
+ });
+
+ const result = schema.safeParse({ password: 'short' });
+ if (!result.success) {
+ const errors = mapZodErrors(result.error);
+ expect(errors.password).toBe('Min 8 chars');
+ }
+ });
+
+ it('handles nested field paths', () => {
+ const schema = z.object({
+ user: z.object({
+ email: z.string().email('Invalid email'),
+ }),
+ });
+
+ const result = schema.safeParse({ user: { email: 'invalid' } });
+ if (!result.success) {
+ const errors = mapZodErrors(result.error);
+ expect(errors['user.email']).toBe('Invalid email');
+ }
+ });
+
+ it('handles array field paths', () => {
+ const schema = z.object({
+ items: z.array(z.string().min(1, 'Required')),
+ });
+
+ const result = schema.safeParse({ items: ['', 'valid'] });
+ if (!result.success) {
+ const errors = mapZodErrors(result.error);
+ expect(errors['items.0']).toBe('Required');
+ }
+ });
+});
+
+describe('validateSync', () => {
+ const schema = z.object({
+ name: z.string().min(2, 'Name too short'),
+ age: z.number().min(0, 'Age must be positive'),
+ });
+
+ it('returns success with data for valid values', () => {
+ const result = validateSync(schema, { name: 'John', age: 25 });
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({ name: 'John', age: 25 });
+ expect(result.errors).toBeNull();
+ });
+
+ it('returns failure with errors for invalid values', () => {
+ const result = validateSync(schema, { name: 'J', age: -5 });
+
+ expect(result.success).toBe(false);
+ expect(result.data).toBeNull();
+ expect(result.errors).toEqual({
+ name: 'Name too short',
+ age: 'Age must be positive',
+ });
+ });
+
+ it('handles missing fields', () => {
+ const result = validateSync(schema, {});
+
+ expect(result.success).toBe(false);
+ expect(result.errors?.name).toBeDefined();
+ expect(result.errors?.age).toBeDefined();
+ });
+});
+
+describe('validateField', () => {
+ it('returns null for valid value', () => {
+ const schema = z.string().email();
+ const result = validateField(schema, 'test@example.com');
+ expect(result).toBeNull();
+ });
+
+ it('returns error message for invalid value', () => {
+ const schema = z.string().email('Invalid email');
+ const result = validateField(schema, 'invalid');
+ expect(result).toBe('Invalid email');
+ });
+
+ it('returns default message when no custom message', () => {
+ const schema = z.string().min(5);
+ const result = validateField(schema, 'hi');
+ expect(result).toBeDefined();
+ });
+});
+
+describe('isEmpty', () => {
+ it('returns true for null', () => {
+ expect(isEmpty(null)).toBe(true);
+ });
+
+ it('returns true for undefined', () => {
+ expect(isEmpty(undefined)).toBe(true);
+ });
+
+ it('returns true for empty string', () => {
+ expect(isEmpty('')).toBe(true);
+ });
+
+ it('returns true for whitespace-only string', () => {
+ expect(isEmpty(' ')).toBe(true);
+ });
+
+ it('returns true for empty array', () => {
+ expect(isEmpty([])).toBe(true);
+ });
+
+ it('returns false for non-empty string', () => {
+ expect(isEmpty('hello')).toBe(false);
+ });
+
+ it('returns false for non-empty array', () => {
+ expect(isEmpty([1, 2, 3])).toBe(false);
+ });
+
+ it('returns false for number', () => {
+ expect(isEmpty(0)).toBe(false);
+ });
+
+ it('returns false for boolean', () => {
+ expect(isEmpty(false)).toBe(false);
+ });
+});
diff --git a/src/core/conditional.ts b/src/core/conditional.ts
new file mode 100644
index 0000000..98b65b9
--- /dev/null
+++ b/src/core/conditional.ts
@@ -0,0 +1,78 @@
+/**
+ * Conditional field visibility logic
+ * Framework-FREE β no React imports allowed in this file
+ */
+
+import type { ConditionalRule, FormValues } from './types';
+
+/**
+ * Evaluate a conditional rule against form values
+ * Used for showWhen/hideWhen field visibility
+ *
+ * @param rule - The conditional rule to evaluate
+ * @param values - Current form values
+ * @returns true if the condition is met
+ *
+ * @example
+ * ```typescript
+ * const rule = { field: 'role', operator: 'equals', value: 'admin' };
+ * const values = { role: 'admin' };
+ * evaluateRule(rule, values); // true
+ * ```
+ */
+export function evaluateRule(rule: ConditionalRule, values: FormValues): boolean {
+ const fieldValue = values[rule.field];
+
+ switch (rule.operator) {
+ case 'equals':
+ return fieldValue === rule.value;
+
+ case 'not_equals':
+ return fieldValue !== rule.value;
+
+ case 'is_empty':
+ return fieldValue === null || fieldValue === undefined || fieldValue === '';
+
+ case 'is_not_empty':
+ return fieldValue !== null && fieldValue !== undefined && fieldValue !== '';
+
+ case 'gt':
+ return Number(fieldValue) > Number(rule.value);
+
+ case 'lt':
+ return Number(fieldValue) < Number(rule.value);
+
+ case 'contains':
+ return String(fieldValue).includes(String(rule.value));
+
+ default:
+ return false;
+ }
+}
+
+/**
+ * Check if a field should be visible based on showWhen/hideWhen rules
+ *
+ * @param showWhen - Rule that must be true for field to show (optional)
+ * @param hideWhen - Rule that when true hides the field (optional)
+ * @param values - Current form values
+ * @returns true if field should be visible
+ */
+export function isFieldVisible(
+ showWhen: ConditionalRule | undefined,
+ hideWhen: ConditionalRule | undefined,
+ values: FormValues,
+): boolean {
+ // If hideWhen is set and evaluates to true, hide the field
+ if (hideWhen && evaluateRule(hideWhen, values)) {
+ return false;
+ }
+
+ // If showWhen is set, field only visible when rule is true
+ if (showWhen) {
+ return evaluateRule(showWhen, values);
+ }
+
+ // No conditions, field is visible by default
+ return true;
+}
diff --git a/src/core/errors.ts b/src/core/errors.ts
new file mode 100644
index 0000000..73a9096
--- /dev/null
+++ b/src/core/errors.ts
@@ -0,0 +1,59 @@
+/**
+ * Custom error classes for FormKit
+ * Framework-FREE β no React imports allowed in this file
+ */
+
+/**
+ * Base error class for FormKit errors
+ */
+export class FormKitError extends Error {
+ public readonly code: string;
+
+ constructor(message: string, code: string = 'FORMKIT_ERROR') {
+ super(message);
+ this.name = 'FormKitError';
+ this.code = code;
+ Object.setPrototypeOf(this, FormKitError.prototype);
+ }
+}
+
+/**
+ * Error thrown when field validation fails
+ */
+export class FieldValidationError extends FormKitError {
+ public readonly fieldKey: string;
+
+ constructor(fieldKey: string, message: string) {
+ super(message, 'FIELD_VALIDATION_ERROR');
+ this.name = 'FieldValidationError';
+ this.fieldKey = fieldKey;
+ Object.setPrototypeOf(this, FieldValidationError.prototype);
+ }
+}
+
+/**
+ * Error thrown when form configuration is invalid
+ */
+export class ConfigurationError extends FormKitError {
+ constructor(message: string) {
+ super(message, 'CONFIGURATION_ERROR');
+ this.name = 'ConfigurationError';
+ Object.setPrototypeOf(this, ConfigurationError.prototype);
+ }
+}
+
+/**
+ * Error thrown when async validation fails due to network or timeout
+ */
+export class AsyncValidationError extends FormKitError {
+ public readonly fieldKey: string;
+ public readonly cause?: Error;
+
+ constructor(fieldKey: string, message: string, cause?: Error) {
+ super(message, 'ASYNC_VALIDATION_ERROR');
+ this.name = 'AsyncValidationError';
+ this.fieldKey = fieldKey;
+ this.cause = cause;
+ Object.setPrototypeOf(this, AsyncValidationError.prototype);
+ }
+}
diff --git a/src/core/grid.ts b/src/core/grid.ts
new file mode 100644
index 0000000..95d70af
--- /dev/null
+++ b/src/core/grid.ts
@@ -0,0 +1,134 @@
+/**
+ * Grid utilities for form layout
+ * Pure functions for generating grid CSS classes
+ */
+
+import type {
+ ColumnCount,
+ ColSpanValue,
+ ResponsiveColumns,
+ ResponsiveColSpan,
+} from '../models/SectionConfig';
+
+/**
+ * Breakpoint prefixes for Tailwind CSS
+ */
+type Breakpoint = 'default' | 'sm' | 'md' | 'lg' | 'xl';
+const BREAKPOINTS: Breakpoint[] = ['default', 'sm', 'md', 'lg', 'xl'];
+
+type ResponsiveValueMap = Partial>;
+
+function buildResponsiveClasses(
+ values: ResponsiveValueMap,
+ basePrefix: string,
+ fallbackDefault?: number,
+): string {
+ const classes: string[] = [];
+ let hasDefault = false;
+
+ for (const bp of BREAKPOINTS) {
+ const value = values[bp];
+ if (value !== undefined) {
+ if (bp === 'default') {
+ classes.push(`${basePrefix}-${value}`);
+ hasDefault = true;
+ } else {
+ classes.push(`${bp}:${basePrefix}-${value}`);
+ }
+ }
+ }
+
+ if (!hasDefault && fallbackDefault !== undefined) {
+ classes.unshift(`${basePrefix}-${fallbackDefault}`);
+ }
+
+ return classes.join(' ');
+}
+
+/**
+ * Check if columns config is responsive (object vs number)
+ */
+function isResponsiveColumns(columns: ColumnCount): columns is ResponsiveColumns {
+ return typeof columns === 'object' && columns !== null;
+}
+
+/**
+ * Check if colSpan config is responsive (object vs number)
+ */
+function isResponsiveColSpan(colSpan: ColSpanValue): colSpan is ResponsiveColSpan {
+ return typeof colSpan === 'object' && colSpan !== null;
+}
+
+/**
+ * Generate grid template columns class for a given column count
+ *
+ * @param columns - Number of columns (1-12) or responsive config
+ * @returns Tailwind CSS class string
+ *
+ * @example
+ * getGridColumnsClass(3) // 'grid-cols-3'
+ * getGridColumnsClass({ default: 1, md: 2, lg: 3 }) // 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
+ */
+export function getGridColumnsClass(columns: ColumnCount = 1): string {
+ if (!isResponsiveColumns(columns)) {
+ return `grid-cols-${columns}`;
+ }
+
+ return buildResponsiveClasses(columns, 'grid-cols');
+}
+
+/**
+ * Generate column span class for a field
+ *
+ * @param colSpan - Column span (1-12) or responsive config
+ * @param defaultSpan - Default span if colSpan is not provided
+ * @returns Tailwind CSS class string
+ *
+ * @example
+ * getColSpanClass(6) // 'col-span-6'
+ * getColSpanClass({ default: 12, md: 6 }) // 'col-span-12 md:col-span-6'
+ */
+export function getColSpanClass(colSpan?: ColSpanValue, defaultSpan: number = 1): string {
+ if (colSpan === undefined) {
+ return `col-span-${defaultSpan}`;
+ }
+
+ if (!isResponsiveColSpan(colSpan)) {
+ return `col-span-${colSpan}`;
+ }
+
+ return buildResponsiveClasses(colSpan, 'col-span', defaultSpan);
+}
+
+/**
+ * Generate gap class
+ *
+ * @param gap - Gap size (Tailwind scale: 1-8)
+ * @returns Tailwind CSS class string
+ */
+export function getGapClass(gap: number = 4): string {
+ return `gap-${gap}`;
+}
+
+/**
+ * Combine grid container classes
+ *
+ * @param columns - Column count or responsive config
+ * @param gap - Gap size
+ * @returns Combined class string
+ */
+export function getGridContainerClass(columns: ColumnCount = 1, gap: number = 4): string {
+ return `grid ${getGridColumnsClass(columns)} ${getGapClass(gap)}`;
+}
+
+/**
+ * Normalize column count to ensure it's valid
+ */
+export function normalizeColumnCount(
+ columns: ColumnCount | undefined,
+ fallback: number = 1,
+): number {
+ if (columns === undefined) return fallback;
+ if (typeof columns === 'number') return columns;
+ return columns.default ?? fallback;
+}
diff --git a/src/core/i18n.ts b/src/core/i18n.ts
new file mode 100644
index 0000000..a472e1b
--- /dev/null
+++ b/src/core/i18n.ts
@@ -0,0 +1,207 @@
+/**
+ * Core i18n types and utilities
+ * Framework-FREE β no React imports allowed in this file
+ */
+
+/**
+ * Supported locales
+ */
+export type Locale = 'en' | 'fr';
+
+/**
+ * Default locale
+ */
+export const DEFAULT_LOCALE: Locale = 'en';
+
+/**
+ * Translation keys structure
+ */
+export interface TranslationKeys {
+ // Form actions
+ form: {
+ submit: string;
+ reset: string;
+ next: string;
+ back: string;
+ confirm: string;
+ submitting: string;
+ };
+
+ // Field actions
+ field: {
+ add: string;
+ remove: string;
+ showPassword: string;
+ hidePassword: string;
+ clearSelection: string;
+ search: string;
+ selectOption: string;
+ noOptions: string;
+ noOptionsFound: string;
+ loading: string;
+ selected: string;
+ typeAndEnter: string;
+ phoneNumber: string;
+ yes: string;
+ no: string;
+ };
+
+ // Accessibility
+ a11y: {
+ formSteps: string;
+ required: string;
+ stepCurrent: string;
+ stepCompleted: string;
+ stepNumber: string;
+ removeItem: string;
+ addItem: string;
+ calendar: string;
+ };
+
+ // Date/Time
+ datetime: {
+ months: {
+ january: string;
+ february: string;
+ march: string;
+ april: string;
+ may: string;
+ june: string;
+ july: string;
+ august: string;
+ september: string;
+ october: string;
+ november: string;
+ december: string;
+ };
+ monthsShort: {
+ jan: string;
+ feb: string;
+ mar: string;
+ apr: string;
+ may: string;
+ jun: string;
+ jul: string;
+ aug: string;
+ sep: string;
+ oct: string;
+ nov: string;
+ dec: string;
+ };
+ days: {
+ sunday: string;
+ monday: string;
+ tuesday: string;
+ wednesday: string;
+ thursday: string;
+ friday: string;
+ saturday: string;
+ };
+ daysShort: {
+ sun: string;
+ mon: string;
+ tue: string;
+ wed: string;
+ thu: string;
+ fri: string;
+ sat: string;
+ };
+ am: string;
+ pm: string;
+ today: string;
+ selectDate: string;
+ selectTime: string;
+ hour: string;
+ minute: string;
+ previousMonth: string;
+ nextMonth: string;
+ dateLabel: string;
+ timeLabel: string;
+ };
+
+ // File upload
+ file: {
+ dragDrop: string;
+ browse: string;
+ remove: string;
+ maxSize: string;
+ invalidType: string;
+ exceedsMaxSize: string;
+ selected: string;
+ accepted: string;
+ };
+
+ // Phone field
+ phone: {
+ searchCountry: string;
+ selectCountry: string;
+ };
+
+ // Rating field
+ rating: {
+ stars: string;
+ outOf: string;
+ noRating: string;
+ };
+
+ // Tags field
+ tags: {
+ addTag: string;
+ removeTag: string;
+ maxTags: string;
+ };
+
+ // Array field
+ array: {
+ empty: string;
+ row: string;
+ rowAdded: string;
+ rowRemoved: string;
+ moveUp: string;
+ moveDown: string;
+ rowMovedUp: string;
+ rowMovedDown: string;
+ expand: string;
+ collapse: string;
+ confirmRemove: string;
+ minHint: string;
+ maxHint: string;
+ minMaxHint: string;
+ };
+}
+
+/**
+ * Get a nested translation value by dot-notation path
+ * @param translations - Translation object
+ * @param path - Dot-notation path (e.g., 'form.submit')
+ * @param params - Optional parameters to interpolate (e.g., { min: 1, max: 5 })
+ * @param fallback - Fallback value if path not found
+ */
+export function getTranslation(
+ translations: TranslationKeys,
+ path: string,
+ params?: Record,
+ fallback?: string,
+): string {
+ const keys = path.split('.');
+ let current: unknown = translations;
+
+ for (const key of keys) {
+ if (current && typeof current === 'object' && key in current) {
+ current = (current as Record)[key];
+ } else {
+ return fallback ?? path;
+ }
+ }
+
+ let result = typeof current === 'string' ? current : (fallback ?? path);
+
+ // Interpolate parameters like {min}, {max}
+ if (params) {
+ for (const [key, value] of Object.entries(params)) {
+ result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
+ }
+ }
+
+ return result;
+}
diff --git a/src/core/index.ts b/src/core/index.ts
new file mode 100644
index 0000000..ebce0d5
--- /dev/null
+++ b/src/core/index.ts
@@ -0,0 +1,34 @@
+/**
+ * Core module exports
+ * Framework-FREE β no React imports in this module
+ */
+
+// Types and constants
+export {
+ FieldType,
+ DEFAULT_DEBOUNCE_MS,
+ MAX_FILE_SIZE_BYTES,
+ type ConditionalOperator,
+ type ConditionalRule,
+ type FieldValue,
+ type FormValues,
+ type InputType,
+ type ValidationMode,
+} from './types';
+
+// Conditional logic
+export { evaluateRule, isFieldVisible } from './conditional';
+
+// Validation utilities
+export { mapZodErrors, validateSync, validateField, isEmpty, type FieldErrors } from './validator';
+
+// Schema helpers
+export { createFieldSchema, mergeSchemas } from './schema-helpers';
+
+// Error classes
+export {
+ FormKitError,
+ FieldValidationError,
+ ConfigurationError,
+ AsyncValidationError,
+} from './errors';
diff --git a/src/core/schema-helpers.ts b/src/core/schema-helpers.ts
new file mode 100644
index 0000000..a8d365a
--- /dev/null
+++ b/src/core/schema-helpers.ts
@@ -0,0 +1,122 @@
+/**
+ * Zod schema helpers for building form schemas declaratively
+ * Framework-FREE β no React imports allowed in this file
+ */
+
+import { z } from 'zod';
+import { FieldType } from './types';
+
+type SchemaOptions = {
+ required?: boolean;
+ min?: number;
+ max?: number;
+ minLength?: number;
+ maxLength?: number;
+ pattern?: RegExp;
+ message?: string;
+};
+
+function createNumberSchema(min?: number, max?: number): z.ZodTypeAny {
+ let schema: z.ZodTypeAny = z.coerce.number();
+ if (min !== undefined) schema = (schema as z.ZodNumber).min(min);
+ if (max !== undefined) schema = (schema as z.ZodNumber).max(max);
+ return schema;
+}
+
+function createArraySchema(min?: number, max?: number): z.ZodTypeAny {
+ let schema: z.ZodTypeAny = z.array(z.unknown());
+ if (min !== undefined) schema = (schema as z.ZodArray).min(min);
+ if (max !== undefined) schema = (schema as z.ZodArray).max(max);
+ return schema;
+}
+
+function createStringSchema(
+ minLength?: number,
+ maxLength?: number,
+ pattern?: RegExp,
+ message?: string,
+): z.ZodTypeAny {
+ let schema: z.ZodTypeAny = z.string();
+ if (minLength !== undefined) schema = (schema as z.ZodString).min(minLength);
+ if (maxLength !== undefined) schema = (schema as z.ZodString).max(maxLength);
+ if (pattern) schema = (schema as z.ZodString).regex(pattern, message);
+ return schema;
+}
+
+function createBaseSchema(type: FieldType, options: SchemaOptions): z.ZodTypeAny {
+ const { min, max, minLength, maxLength, pattern, message } = options;
+
+ switch (type) {
+ case FieldType.EMAIL:
+ return z.string().email(message ?? 'Invalid email address');
+ case FieldType.NUMBER:
+ return createNumberSchema(min, max);
+ case FieldType.CHECKBOX:
+ case FieldType.SWITCH:
+ return z.boolean();
+ case FieldType.DATE:
+ return z.coerce.date();
+ case FieldType.FILE:
+ return z.instanceof(File);
+ case FieldType.MULTI_SELECT:
+ case FieldType.ARRAY:
+ return createArraySchema(min, max);
+ default:
+ return createStringSchema(minLength, maxLength, pattern, message);
+ }
+}
+
+/**
+ * Create a Zod schema for a specific field type
+ *
+ * @param type - The field type
+ * @param options - Schema options
+ * @returns Zod schema for the field
+ *
+ * @example
+ * ```typescript
+ * const emailSchema = createFieldSchema(FieldType.EMAIL, { required: true });
+ * const numberSchema = createFieldSchema(FieldType.NUMBER, { min: 0, max: 100 });
+ * ```
+ */
+export function createFieldSchema(type: FieldType, options: SchemaOptions = {}): z.ZodTypeAny {
+ const { required = false } = options;
+
+ let schema = createBaseSchema(type, options);
+
+ // Make optional if not required
+ if (!required) {
+ schema = schema.optional();
+ }
+
+ return schema;
+}
+
+/**
+ * Merge multiple Zod object schemas into one
+ *
+ * @param schemas - Array of Zod object schemas to merge
+ * @returns Combined Zod object schema
+ *
+ * @example
+ * ```typescript
+ * const step1Schema = z.object({ name: z.string() });
+ * const step2Schema = z.object({ email: z.string().email() });
+ * const fullSchema = mergeSchemas([step1Schema, step2Schema]);
+ * // { name: string, email: string }
+ * ```
+ */
+export function mergeSchemas(schemas: {
+ [K in keyof T]: z.ZodObject;
+}): z.ZodObject {
+ if (schemas.length === 0) {
+ return z.object({});
+ }
+
+ let merged = schemas[0];
+ for (let i = 1; i < schemas.length; i++) {
+ merged = merged.merge(schemas[i]) as typeof merged;
+ }
+
+ return merged as z.ZodObject;
+}
diff --git a/src/core/types.ts b/src/core/types.ts
new file mode 100644
index 0000000..ab9571e
--- /dev/null
+++ b/src/core/types.ts
@@ -0,0 +1,143 @@
+/**
+ * Core type definitions for FormKit UI
+ * Framework-FREE β no React imports allowed in this file
+ */
+
+/**
+ * Field types supported by DynamicForm
+ *
+ * @example
+ * ```typescript
+ * const fields: FieldConfig[] = [
+ * { key: 'email', label: 'Email', type: FieldType.EMAIL },
+ * { key: 'role', label: 'Role', type: FieldType.SELECT, options: [...] },
+ * ];
+ * ```
+ */
+export enum FieldType {
+ /** Single-line text input */
+ TEXT = 'text',
+ /** Email input with validation */
+ EMAIL = 'email',
+ /** Password input (masked) */
+ PASSWORD = 'password',
+ /** Numeric input */
+ NUMBER = 'number',
+ /** Multi-line text area */
+ TEXTAREA = 'textarea',
+ /** Dropdown select */
+ SELECT = 'select',
+ /** Multi-select with checkboxes */
+ MULTI_SELECT = 'multi-select',
+ /** Single checkbox (boolean) */
+ CHECKBOX = 'checkbox',
+ /** Radio button group */
+ RADIO = 'radio',
+ /** Toggle switch (boolean) */
+ SWITCH = 'switch',
+ /** Date picker */
+ DATE = 'date',
+ /** File upload */
+ FILE = 'file',
+ /** Phone number with country code */
+ PHONE = 'phone',
+ /** Range slider */
+ SLIDER = 'slider',
+ /** Dual-thumb range slider */
+ RANGE_SLIDER = 'range-slider',
+ /** OTP / verification code input */
+ OTP = 'otp',
+ /** Multi-tag input */
+ TAGS = 'tags',
+ /** Star rating input */
+ RATING = 'rating',
+ /** Time input */
+ TIME = 'time',
+ /** Combined date and time input */
+ DATETIME = 'datetime',
+ /** Repeatable field group */
+ ARRAY = 'array',
+}
+
+/**
+ * Conditional rule operators for showWhen/hideWhen
+ *
+ * - `equals`: Field value equals the specified value
+ * - `not_equals`: Field value does not equal the specified value
+ * - `contains`: Field value contains the specified substring (strings only)
+ * - `is_empty`: Field is empty, null, or undefined
+ * - `is_not_empty`: Field has a non-empty value
+ * - `gt`: Field value is greater than the specified number
+ * - `lt`: Field value is less than the specified number
+ */
+export type ConditionalOperator =
+ | 'equals'
+ | 'not_equals'
+ | 'contains'
+ | 'is_empty'
+ | 'is_not_empty'
+ | 'gt'
+ | 'lt';
+
+/**
+ * Conditional rule for field visibility
+ */
+export interface ConditionalRule {
+ /** Field key to check */
+ readonly field: string;
+ /** Comparison operator */
+ readonly operator: ConditionalOperator;
+ /** Value to compare against (not required for is_empty/is_not_empty) */
+ readonly value?: unknown;
+}
+
+/**
+ * Possible field value types
+ */
+export type FieldValue =
+ | string
+ | number
+ | boolean
+ | null
+ | undefined
+ | File
+ | File[]
+ | string[]
+ | number[]
+ | FieldValue[]
+ | Record
+ | Record[];
+
+/**
+ * Form values object
+ */
+export type FormValues = Record;
+
+/**
+ * HTML input types supported by FormKit
+ */
+export type InputType =
+ | 'text'
+ | 'email'
+ | 'password'
+ | 'number'
+ | 'tel'
+ | 'url'
+ | 'search'
+ | 'date'
+ | 'time'
+ | 'datetime-local'
+ | 'month'
+ | 'week'
+ | 'color';
+
+/**
+ * Validation trigger timing
+ */
+export type ValidationMode = 'onSubmit' | 'onChange' | 'onBlur';
+
+/**
+ * Default configuration constants
+ */
+export const DEFAULT_DEBOUNCE_MS = 300;
+export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
diff --git a/src/core/validator.ts b/src/core/validator.ts
new file mode 100644
index 0000000..964ee7b
--- /dev/null
+++ b/src/core/validator.ts
@@ -0,0 +1,93 @@
+/**
+ * Validation utilities and Zod error mapping
+ * Framework-FREE β no React imports allowed in this file
+ */
+
+import type { z } from 'zod';
+import type { FormValues, FieldValue } from './types';
+
+/**
+ * Field errors mapping (field key β error message)
+ */
+export type FieldErrors = Partial>;
+
+/**
+ * Map Zod validation errors to field-level error messages
+ *
+ * @param zodError - Zod error object
+ * @returns Object mapping field keys to error messages
+ *
+ * @example
+ * ```typescript
+ * const result = schema.safeParse(values);
+ * if (!result.success) {
+ * const errors = mapZodErrors(result.error);
+ * // { email: 'Invalid email', name: 'Required' }
+ * }
+ * ```
+ */
+export function mapZodErrors(zodError: z.ZodError): FieldErrors {
+ const errors: FieldErrors = {};
+
+ for (const issue of zodError.issues) {
+ // Get the field path (first element for simple fields, joined for nested)
+ const fieldKey = issue.path.length > 0 ? issue.path.join('.') : '_root';
+
+ // Only keep the first error per field
+ if (!(fieldKey in errors)) {
+ errors[fieldKey as keyof T] = issue.message;
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Run synchronous validation against a Zod schema
+ *
+ * @param schema - Zod schema to validate against
+ * @param values - Form values to validate
+ * @returns Object with success flag, data (if valid), and errors (if invalid)
+ */
+export function validateSync(
+ schema: z.ZodType,
+ values: unknown,
+):
+ | { success: true; data: T; errors: null }
+ | { success: false; data: null; errors: FieldErrors } {
+ const result = schema.safeParse(values);
+
+ if (result.success) {
+ return { success: true, data: result.data, errors: null };
+ }
+
+ return { success: false, data: null, errors: mapZodErrors(result.error) };
+}
+
+/**
+ * Validate a single field value against a Zod schema
+ *
+ * @param schema - Zod schema for the field
+ * @param value - Field value to validate
+ * @returns Error message or null if valid
+ */
+export function validateField(schema: z.ZodType, value: FieldValue): string | null {
+ const result = schema.safeParse(value);
+
+ if (result.success) {
+ return null;
+ }
+
+ // Return first error message
+ return result.error.issues[0]?.message ?? 'Invalid value';
+}
+
+/**
+ * Check if a value is empty (for required field validation)
+ */
+export function isEmpty(value: FieldValue): boolean {
+ if (value === null || value === undefined) return true;
+ if (typeof value === 'string') return value.trim() === '';
+ if (Array.isArray(value)) return value.length === 0;
+ return false;
+}
diff --git a/src/data/countries.json b/src/data/countries.json
new file mode 100644
index 0000000..4f0a7b8
--- /dev/null
+++ b/src/data/countries.json
@@ -0,0 +1,243 @@
+{
+ "source": "ISO 3166-1, ITU-T E.164",
+ "flagCdn": "https://flagcdn.com",
+ "flagFormat": "{cdn}/w40/{code}.png or {cdn}/{code}.svg",
+ "countries": [
+ { "code": "AF", "name": "Afghanistan", "dialCode": "+93" },
+ { "code": "AL", "name": "Albania", "dialCode": "+355" },
+ { "code": "DZ", "name": "Algeria", "dialCode": "+213" },
+ { "code": "AS", "name": "American Samoa", "dialCode": "+1684" },
+ { "code": "AD", "name": "Andorra", "dialCode": "+376" },
+ { "code": "AO", "name": "Angola", "dialCode": "+244" },
+ { "code": "AI", "name": "Anguilla", "dialCode": "+1264" },
+ { "code": "AG", "name": "Antigua and Barbuda", "dialCode": "+1268" },
+ { "code": "AR", "name": "Argentina", "dialCode": "+54" },
+ { "code": "AM", "name": "Armenia", "dialCode": "+374" },
+ { "code": "AW", "name": "Aruba", "dialCode": "+297" },
+ { "code": "AU", "name": "Australia", "dialCode": "+61" },
+ { "code": "AT", "name": "Austria", "dialCode": "+43" },
+ { "code": "AZ", "name": "Azerbaijan", "dialCode": "+994" },
+ { "code": "BS", "name": "Bahamas", "dialCode": "+1242" },
+ { "code": "BH", "name": "Bahrain", "dialCode": "+973" },
+ { "code": "BD", "name": "Bangladesh", "dialCode": "+880" },
+ { "code": "BB", "name": "Barbados", "dialCode": "+1246" },
+ { "code": "BY", "name": "Belarus", "dialCode": "+375" },
+ { "code": "BE", "name": "Belgium", "dialCode": "+32" },
+ { "code": "BZ", "name": "Belize", "dialCode": "+501" },
+ { "code": "BJ", "name": "Benin", "dialCode": "+229" },
+ { "code": "BM", "name": "Bermuda", "dialCode": "+1441" },
+ { "code": "BT", "name": "Bhutan", "dialCode": "+975" },
+ { "code": "BO", "name": "Bolivia", "dialCode": "+591" },
+ { "code": "BA", "name": "Bosnia and Herzegovina", "dialCode": "+387" },
+ { "code": "BW", "name": "Botswana", "dialCode": "+267" },
+ { "code": "BR", "name": "Brazil", "dialCode": "+55" },
+ { "code": "BN", "name": "Brunei", "dialCode": "+673" },
+ { "code": "BG", "name": "Bulgaria", "dialCode": "+359" },
+ { "code": "BF", "name": "Burkina Faso", "dialCode": "+226" },
+ { "code": "BI", "name": "Burundi", "dialCode": "+257" },
+ { "code": "KH", "name": "Cambodia", "dialCode": "+855" },
+ { "code": "CM", "name": "Cameroon", "dialCode": "+237" },
+ { "code": "CA", "name": "Canada", "dialCode": "+1" },
+ { "code": "CV", "name": "Cape Verde", "dialCode": "+238" },
+ { "code": "KY", "name": "Cayman Islands", "dialCode": "+1345" },
+ { "code": "CF", "name": "Central African Republic", "dialCode": "+236" },
+ { "code": "TD", "name": "Chad", "dialCode": "+235" },
+ { "code": "CL", "name": "Chile", "dialCode": "+56" },
+ { "code": "CN", "name": "China", "dialCode": "+86" },
+ { "code": "CO", "name": "Colombia", "dialCode": "+57" },
+ { "code": "KM", "name": "Comoros", "dialCode": "+269" },
+ { "code": "CG", "name": "Congo", "dialCode": "+242" },
+ { "code": "CD", "name": "Congo (DRC)", "dialCode": "+243" },
+ { "code": "CK", "name": "Cook Islands", "dialCode": "+682" },
+ { "code": "CR", "name": "Costa Rica", "dialCode": "+506" },
+ { "code": "HR", "name": "Croatia", "dialCode": "+385" },
+ { "code": "CU", "name": "Cuba", "dialCode": "+53" },
+ { "code": "CW", "name": "CuraΓ§ao", "dialCode": "+599" },
+ { "code": "CY", "name": "Cyprus", "dialCode": "+357" },
+ { "code": "CZ", "name": "Czech Republic", "dialCode": "+420" },
+ { "code": "DK", "name": "Denmark", "dialCode": "+45" },
+ { "code": "DJ", "name": "Djibouti", "dialCode": "+253" },
+ { "code": "DM", "name": "Dominica", "dialCode": "+1767" },
+ { "code": "DO", "name": "Dominican Republic", "dialCode": "+1809" },
+ { "code": "EC", "name": "Ecuador", "dialCode": "+593" },
+ { "code": "EG", "name": "Egypt", "dialCode": "+20" },
+ { "code": "SV", "name": "El Salvador", "dialCode": "+503" },
+ { "code": "GQ", "name": "Equatorial Guinea", "dialCode": "+240" },
+ { "code": "ER", "name": "Eritrea", "dialCode": "+291" },
+ { "code": "EE", "name": "Estonia", "dialCode": "+372" },
+ { "code": "SZ", "name": "Eswatini", "dialCode": "+268" },
+ { "code": "ET", "name": "Ethiopia", "dialCode": "+251" },
+ { "code": "FK", "name": "Falkland Islands", "dialCode": "+500" },
+ { "code": "FO", "name": "Faroe Islands", "dialCode": "+298" },
+ { "code": "FJ", "name": "Fiji", "dialCode": "+679" },
+ { "code": "FI", "name": "Finland", "dialCode": "+358" },
+ { "code": "FR", "name": "France", "dialCode": "+33" },
+ { "code": "GF", "name": "French Guiana", "dialCode": "+594" },
+ { "code": "PF", "name": "French Polynesia", "dialCode": "+689" },
+ { "code": "GA", "name": "Gabon", "dialCode": "+241" },
+ { "code": "GM", "name": "Gambia", "dialCode": "+220" },
+ { "code": "GE", "name": "Georgia", "dialCode": "+995" },
+ { "code": "DE", "name": "Germany", "dialCode": "+49" },
+ { "code": "GH", "name": "Ghana", "dialCode": "+233" },
+ { "code": "GI", "name": "Gibraltar", "dialCode": "+350" },
+ { "code": "GR", "name": "Greece", "dialCode": "+30" },
+ { "code": "GL", "name": "Greenland", "dialCode": "+299" },
+ { "code": "GD", "name": "Grenada", "dialCode": "+1473" },
+ { "code": "GP", "name": "Guadeloupe", "dialCode": "+590" },
+ { "code": "GU", "name": "Guam", "dialCode": "+1671" },
+ { "code": "GT", "name": "Guatemala", "dialCode": "+502" },
+ { "code": "GG", "name": "Guernsey", "dialCode": "+44" },
+ { "code": "GN", "name": "Guinea", "dialCode": "+224" },
+ { "code": "GW", "name": "Guinea-Bissau", "dialCode": "+245" },
+ { "code": "GY", "name": "Guyana", "dialCode": "+592" },
+ { "code": "HT", "name": "Haiti", "dialCode": "+509" },
+ { "code": "HN", "name": "Honduras", "dialCode": "+504" },
+ { "code": "HK", "name": "Hong Kong", "dialCode": "+852" },
+ { "code": "HU", "name": "Hungary", "dialCode": "+36" },
+ { "code": "IS", "name": "Iceland", "dialCode": "+354" },
+ { "code": "IN", "name": "India", "dialCode": "+91" },
+ { "code": "ID", "name": "Indonesia", "dialCode": "+62" },
+ { "code": "IR", "name": "Iran", "dialCode": "+98" },
+ { "code": "IQ", "name": "Iraq", "dialCode": "+964" },
+ { "code": "IE", "name": "Ireland", "dialCode": "+353" },
+ { "code": "IM", "name": "Isle of Man", "dialCode": "+44" },
+ { "code": "IL", "name": "Israel", "dialCode": "+972" },
+ { "code": "IT", "name": "Italy", "dialCode": "+39" },
+ { "code": "CI", "name": "Ivory Coast", "dialCode": "+225" },
+ { "code": "JM", "name": "Jamaica", "dialCode": "+1876" },
+ { "code": "JP", "name": "Japan", "dialCode": "+81" },
+ { "code": "JE", "name": "Jersey", "dialCode": "+44" },
+ { "code": "JO", "name": "Jordan", "dialCode": "+962" },
+ { "code": "KZ", "name": "Kazakhstan", "dialCode": "+7" },
+ { "code": "KE", "name": "Kenya", "dialCode": "+254" },
+ { "code": "KI", "name": "Kiribati", "dialCode": "+686" },
+ { "code": "XK", "name": "Kosovo", "dialCode": "+383" },
+ { "code": "KW", "name": "Kuwait", "dialCode": "+965" },
+ { "code": "KG", "name": "Kyrgyzstan", "dialCode": "+996" },
+ { "code": "LA", "name": "Laos", "dialCode": "+856" },
+ { "code": "LV", "name": "Latvia", "dialCode": "+371" },
+ { "code": "LB", "name": "Lebanon", "dialCode": "+961" },
+ { "code": "LS", "name": "Lesotho", "dialCode": "+266" },
+ { "code": "LR", "name": "Liberia", "dialCode": "+231" },
+ { "code": "LY", "name": "Libya", "dialCode": "+218" },
+ { "code": "LI", "name": "Liechtenstein", "dialCode": "+423" },
+ { "code": "LT", "name": "Lithuania", "dialCode": "+370" },
+ { "code": "LU", "name": "Luxembourg", "dialCode": "+352" },
+ { "code": "MO", "name": "Macau", "dialCode": "+853" },
+ { "code": "MK", "name": "North Macedonia", "dialCode": "+389" },
+ { "code": "MG", "name": "Madagascar", "dialCode": "+261" },
+ { "code": "MW", "name": "Malawi", "dialCode": "+265" },
+ { "code": "MY", "name": "Malaysia", "dialCode": "+60" },
+ { "code": "MV", "name": "Maldives", "dialCode": "+960" },
+ { "code": "ML", "name": "Mali", "dialCode": "+223" },
+ { "code": "MT", "name": "Malta", "dialCode": "+356" },
+ { "code": "MH", "name": "Marshall Islands", "dialCode": "+692" },
+ { "code": "MQ", "name": "Martinique", "dialCode": "+596" },
+ { "code": "MR", "name": "Mauritania", "dialCode": "+222" },
+ { "code": "MU", "name": "Mauritius", "dialCode": "+230" },
+ { "code": "YT", "name": "Mayotte", "dialCode": "+262" },
+ { "code": "MX", "name": "Mexico", "dialCode": "+52" },
+ { "code": "FM", "name": "Micronesia", "dialCode": "+691" },
+ { "code": "MD", "name": "Moldova", "dialCode": "+373" },
+ { "code": "MC", "name": "Monaco", "dialCode": "+377" },
+ { "code": "MN", "name": "Mongolia", "dialCode": "+976" },
+ { "code": "ME", "name": "Montenegro", "dialCode": "+382" },
+ { "code": "MS", "name": "Montserrat", "dialCode": "+1664" },
+ { "code": "MA", "name": "Morocco", "dialCode": "+212" },
+ { "code": "MZ", "name": "Mozambique", "dialCode": "+258" },
+ { "code": "MM", "name": "Myanmar", "dialCode": "+95" },
+ { "code": "NA", "name": "Namibia", "dialCode": "+264" },
+ { "code": "NR", "name": "Nauru", "dialCode": "+674" },
+ { "code": "NP", "name": "Nepal", "dialCode": "+977" },
+ { "code": "NL", "name": "Netherlands", "dialCode": "+31" },
+ { "code": "NC", "name": "New Caledonia", "dialCode": "+687" },
+ { "code": "NZ", "name": "New Zealand", "dialCode": "+64" },
+ { "code": "NI", "name": "Nicaragua", "dialCode": "+505" },
+ { "code": "NE", "name": "Niger", "dialCode": "+227" },
+ { "code": "NG", "name": "Nigeria", "dialCode": "+234" },
+ { "code": "NU", "name": "Niue", "dialCode": "+683" },
+ { "code": "NF", "name": "Norfolk Island", "dialCode": "+672" },
+ { "code": "KP", "name": "North Korea", "dialCode": "+850" },
+ { "code": "MP", "name": "Northern Mariana Islands", "dialCode": "+1670" },
+ { "code": "NO", "name": "Norway", "dialCode": "+47" },
+ { "code": "OM", "name": "Oman", "dialCode": "+968" },
+ { "code": "PK", "name": "Pakistan", "dialCode": "+92" },
+ { "code": "PW", "name": "Palau", "dialCode": "+680" },
+ { "code": "PS", "name": "Palestine", "dialCode": "+970" },
+ { "code": "PA", "name": "Panama", "dialCode": "+507" },
+ { "code": "PG", "name": "Papua New Guinea", "dialCode": "+675" },
+ { "code": "PY", "name": "Paraguay", "dialCode": "+595" },
+ { "code": "PE", "name": "Peru", "dialCode": "+51" },
+ { "code": "PH", "name": "Philippines", "dialCode": "+63" },
+ { "code": "PL", "name": "Poland", "dialCode": "+48" },
+ { "code": "PT", "name": "Portugal", "dialCode": "+351" },
+ { "code": "PR", "name": "Puerto Rico", "dialCode": "+1787" },
+ { "code": "QA", "name": "Qatar", "dialCode": "+974" },
+ { "code": "RE", "name": "RΓ©union", "dialCode": "+262" },
+ { "code": "RO", "name": "Romania", "dialCode": "+40" },
+ { "code": "RU", "name": "Russia", "dialCode": "+7" },
+ { "code": "RW", "name": "Rwanda", "dialCode": "+250" },
+ { "code": "BL", "name": "Saint BarthΓ©lemy", "dialCode": "+590" },
+ { "code": "SH", "name": "Saint Helena", "dialCode": "+290" },
+ { "code": "KN", "name": "Saint Kitts and Nevis", "dialCode": "+1869" },
+ { "code": "LC", "name": "Saint Lucia", "dialCode": "+1758" },
+ { "code": "MF", "name": "Saint Martin", "dialCode": "+590" },
+ { "code": "PM", "name": "Saint Pierre and Miquelon", "dialCode": "+508" },
+ { "code": "VC", "name": "Saint Vincent and the Grenadines", "dialCode": "+1784" },
+ { "code": "WS", "name": "Samoa", "dialCode": "+685" },
+ { "code": "SM", "name": "San Marino", "dialCode": "+378" },
+ { "code": "ST", "name": "SΓ£o TomΓ© and PrΓncipe", "dialCode": "+239" },
+ { "code": "SA", "name": "Saudi Arabia", "dialCode": "+966" },
+ { "code": "SN", "name": "Senegal", "dialCode": "+221" },
+ { "code": "RS", "name": "Serbia", "dialCode": "+381" },
+ { "code": "SC", "name": "Seychelles", "dialCode": "+248" },
+ { "code": "SL", "name": "Sierra Leone", "dialCode": "+232" },
+ { "code": "SG", "name": "Singapore", "dialCode": "+65" },
+ { "code": "SX", "name": "Sint Maarten", "dialCode": "+1721" },
+ { "code": "SK", "name": "Slovakia", "dialCode": "+421" },
+ { "code": "SI", "name": "Slovenia", "dialCode": "+386" },
+ { "code": "SB", "name": "Solomon Islands", "dialCode": "+677" },
+ { "code": "SO", "name": "Somalia", "dialCode": "+252" },
+ { "code": "ZA", "name": "South Africa", "dialCode": "+27" },
+ { "code": "KR", "name": "South Korea", "dialCode": "+82" },
+ { "code": "SS", "name": "South Sudan", "dialCode": "+211" },
+ { "code": "ES", "name": "Spain", "dialCode": "+34" },
+ { "code": "LK", "name": "Sri Lanka", "dialCode": "+94" },
+ { "code": "SD", "name": "Sudan", "dialCode": "+249" },
+ { "code": "SR", "name": "Suriname", "dialCode": "+597" },
+ { "code": "SE", "name": "Sweden", "dialCode": "+46" },
+ { "code": "CH", "name": "Switzerland", "dialCode": "+41" },
+ { "code": "SY", "name": "Syria", "dialCode": "+963" },
+ { "code": "TW", "name": "Taiwan", "dialCode": "+886" },
+ { "code": "TJ", "name": "Tajikistan", "dialCode": "+992" },
+ { "code": "TZ", "name": "Tanzania", "dialCode": "+255" },
+ { "code": "TH", "name": "Thailand", "dialCode": "+66" },
+ { "code": "TL", "name": "Timor-Leste", "dialCode": "+670" },
+ { "code": "TG", "name": "Togo", "dialCode": "+228" },
+ { "code": "TK", "name": "Tokelau", "dialCode": "+690" },
+ { "code": "TO", "name": "Tonga", "dialCode": "+676" },
+ { "code": "TT", "name": "Trinidad and Tobago", "dialCode": "+1868" },
+ { "code": "TN", "name": "Tunisia", "dialCode": "+216" },
+ { "code": "TR", "name": "Turkey", "dialCode": "+90" },
+ { "code": "TM", "name": "Turkmenistan", "dialCode": "+993" },
+ { "code": "TC", "name": "Turks and Caicos Islands", "dialCode": "+1649" },
+ { "code": "TV", "name": "Tuvalu", "dialCode": "+688" },
+ { "code": "UG", "name": "Uganda", "dialCode": "+256" },
+ { "code": "UA", "name": "Ukraine", "dialCode": "+380" },
+ { "code": "AE", "name": "United Arab Emirates", "dialCode": "+971" },
+ { "code": "GB", "name": "United Kingdom", "dialCode": "+44" },
+ { "code": "US", "name": "United States", "dialCode": "+1" },
+ { "code": "UY", "name": "Uruguay", "dialCode": "+598" },
+ { "code": "UZ", "name": "Uzbekistan", "dialCode": "+998" },
+ { "code": "VU", "name": "Vanuatu", "dialCode": "+678" },
+ { "code": "VA", "name": "Vatican City", "dialCode": "+379" },
+ { "code": "VE", "name": "Venezuela", "dialCode": "+58" },
+ { "code": "VN", "name": "Vietnam", "dialCode": "+84" },
+ { "code": "VG", "name": "British Virgin Islands", "dialCode": "+1284" },
+ { "code": "VI", "name": "U.S. Virgin Islands", "dialCode": "+1340" },
+ { "code": "WF", "name": "Wallis and Futuna", "dialCode": "+681" },
+ { "code": "YE", "name": "Yemen", "dialCode": "+967" },
+ { "code": "ZM", "name": "Zambia", "dialCode": "+260" },
+ { "code": "ZW", "name": "Zimbabwe", "dialCode": "+263" }
+ ]
+}
diff --git a/src/hooks/FormKitContext.ts b/src/hooks/FormKitContext.ts
new file mode 100644
index 0000000..b4f69f1
--- /dev/null
+++ b/src/hooks/FormKitContext.ts
@@ -0,0 +1,47 @@
+/**
+ * FormKitContext - React context for form state propagation
+ * This file lives in hooks/ to maintain proper layer separation.
+ * Components import from here, ensuring hooks don't import from components.
+ */
+
+import { createContext, useContext } from 'react';
+import type { FormContextValue } from '../models/FormState';
+import type { FormValues } from '../core/types';
+
+/**
+ * Form context for internal use
+ * Used by FormKitProvider in components/context/ and hooks that need form state
+ */
+export const FormKitContext = createContext(null);
+
+FormKitContext.displayName = 'FormKitContext';
+
+/**
+ * Hook to access form context from deep in the component tree
+ * Throws if used outside of FormKitProvider (i.e., outside DynamicForm)
+ *
+ * @returns Form context value
+ * @throws Error if used outside FormKitProvider
+ *
+ * @example
+ * ```tsx
+ * function CustomField() {
+ * const { getValue, setValue, getError } = useFormKitContext();
+ * // ...
+ * }
+ * ```
+ */
+export function useFormKitContext<
+ TValues extends FormValues = FormValues,
+>(): FormContextValue {
+ const context = useContext(FormKitContext);
+
+ if (!context) {
+ throw new Error(
+ 'useFormKitContext must be used within a DynamicForm. ' +
+ 'Make sure your component is rendered inside .',
+ );
+ }
+
+ return context as FormContextValue;
+}
diff --git a/src/hooks/__tests__/useAsyncValidation.test.ts b/src/hooks/__tests__/useAsyncValidation.test.ts
new file mode 100644
index 0000000..92841aa
--- /dev/null
+++ b/src/hooks/__tests__/useAsyncValidation.test.ts
@@ -0,0 +1,311 @@
+/**
+ * Tests for hooks/useAsyncValidation.ts
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useAsyncValidation } from '../useAsyncValidation';
+
+describe('useAsyncValidation', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe('initialization', () => {
+ it('initializes with no error and not validating', () => {
+ const validate = vi.fn().mockResolvedValue(null);
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ }),
+ );
+
+ expect(result.current.error).toBeNull();
+ expect(result.current.isValidating).toBe(false);
+ });
+ });
+
+ describe('debounce behavior', () => {
+ it('does not validate before debounce time', async () => {
+ const validate = vi.fn().mockResolvedValue(null);
+
+ renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ debounceMs: 300,
+ }),
+ );
+
+ // Advance less than debounce time
+ await act(async () => {
+ vi.advanceTimersByTime(200);
+ });
+
+ expect(validate).not.toHaveBeenCalled();
+ });
+
+ it('validates after debounce time', async () => {
+ const validate = vi.fn().mockResolvedValue(null);
+
+ renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test@example.com',
+ debounceMs: 300,
+ }),
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(350);
+ });
+
+ expect(validate).toHaveBeenCalledWith('test@example.com', {
+ signal: expect.any(AbortSignal),
+ });
+ });
+
+ it('uses default debounce of 300ms', async () => {
+ const validate = vi.fn().mockResolvedValue(null);
+
+ renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ }),
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(250);
+ });
+
+ expect(validate).not.toHaveBeenCalled();
+
+ await act(async () => {
+ vi.advanceTimersByTime(100);
+ });
+
+ expect(validate).toHaveBeenCalled();
+ });
+ });
+
+ describe('validation result', () => {
+ it('sets error when validator returns error message', async () => {
+ const validate = vi.fn().mockResolvedValue('Email already taken');
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test@example.com',
+ debounceMs: 100,
+ }),
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ await vi.runAllTimersAsync();
+ });
+
+ expect(result.current.error).toBe('Email already taken');
+ });
+
+ it('clears error when validator returns null', async () => {
+ const validate = vi.fn().mockResolvedValue(null);
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'valid@example.com',
+ debounceMs: 100,
+ }),
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ await vi.runAllTimersAsync();
+ });
+
+ expect(result.current.error).toBeNull();
+ });
+ });
+
+ describe('isValidating state', () => {
+ it('sets isValidating to true during validation', async () => {
+ let resolveValidation: (value: string | null) => void;
+ const validationPromise = new Promise((resolve) => {
+ resolveValidation = resolve;
+ });
+ const validate = vi.fn().mockReturnValue(validationPromise);
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ debounceMs: 100,
+ }),
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ });
+
+ // Should be validating
+ expect(result.current.isValidating).toBe(true);
+
+ await act(async () => {
+ resolveValidation!(null);
+ });
+
+ expect(result.current.isValidating).toBe(false);
+ });
+ });
+
+ describe('enabled option', () => {
+ it('skips validation when disabled', async () => {
+ const validate = vi.fn().mockResolvedValue('error');
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ enabled: false,
+ debounceMs: 100,
+ }),
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(200);
+ });
+
+ expect(validate).not.toHaveBeenCalled();
+ expect(result.current.error).toBeNull();
+ expect(result.current.isValidating).toBe(false);
+ });
+
+ it('returns null error when disabled even if internal state has error', () => {
+ const validate = vi.fn().mockResolvedValue('error');
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ enabled: false,
+ }),
+ );
+
+ // Error should be null when disabled
+ expect(result.current.error).toBeNull();
+ });
+ });
+
+ describe('abort behavior', () => {
+ it('aborts previous validation when value changes', async () => {
+ const validate = vi.fn((_value, { signal }) => {
+ return new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => resolve(null), 500);
+ signal.addEventListener('abort', () => {
+ clearTimeout(timeoutId);
+ reject(new DOMException('Aborted', 'AbortError'));
+ });
+ });
+ });
+
+ const { rerender } = renderHook(
+ ({ value }) =>
+ useAsyncValidation({
+ validate,
+ value,
+ debounceMs: 100,
+ }),
+ { initialProps: { value: 'first' } },
+ );
+
+ // Start first validation
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ });
+
+ expect(validate).toHaveBeenCalledTimes(1);
+
+ // Change value before first completes
+ rerender({ value: 'second' });
+
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ });
+
+ expect(validate).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('triggerValidation', () => {
+ it('manually triggers validation', async () => {
+ const validate = vi.fn().mockResolvedValue('manual error');
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ debounceMs: 1000, // Long debounce
+ }),
+ );
+
+ // Trigger immediately without waiting for debounce
+ await act(async () => {
+ result.current.triggerValidation();
+ await vi.runAllTimersAsync();
+ });
+
+ expect(validate).toHaveBeenCalled();
+ expect(result.current.error).toBe('manual error');
+ });
+ });
+
+ describe('error handling', () => {
+ it('sets generic error message when validator throws non-abort error', async () => {
+ const validate = vi.fn().mockRejectedValue(new Error('Network error'));
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ debounceMs: 100,
+ }),
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ await vi.runAllTimersAsync();
+ });
+
+ expect(result.current.error).toBe('Validation failed');
+ });
+
+ it('ignores AbortError from DOMException', async () => {
+ const abortError = new DOMException('Aborted', 'AbortError');
+ const validate = vi.fn().mockRejectedValue(abortError);
+
+ const { result } = renderHook(() =>
+ useAsyncValidation({
+ validate,
+ value: 'test',
+ debounceMs: 100,
+ }),
+ );
+
+ await act(async () => {
+ vi.advanceTimersByTime(150);
+ await vi.runAllTimersAsync();
+ });
+
+ // Should not set error for abort
+ expect(result.current.error).toBeNull();
+ });
+ });
+});
diff --git a/src/hooks/__tests__/useFormContext.test.tsx b/src/hooks/__tests__/useFormContext.test.tsx
new file mode 100644
index 0000000..096b3fd
--- /dev/null
+++ b/src/hooks/__tests__/useFormContext.test.tsx
@@ -0,0 +1,188 @@
+/**
+ * Tests for useFormContext hook
+ */
+
+import { describe, it, expect, vi, type Mock } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import FormKitProvider from '../../components/context/FormKitContext';
+import { useFormKitContext } from '../FormKitContext';
+import { useFormContext } from '../useFormContext';
+import type { FormValues, FieldValue } from '../../core/types';
+import type { FormContextValue } from '../../models/FormState';
+
+// Type for mock context that allows access to mock methods
+interface MockContext {
+ getValue: Mock<(key: keyof FormValues) => FieldValue>;
+ setValue: Mock<(key: keyof FormValues, value: FieldValue) => void>;
+ getError: Mock<(key: keyof FormValues) => string | null>;
+ setError: Mock<(key: keyof FormValues, error: string | null) => void>;
+ getTouched: Mock<(key: keyof FormValues) => boolean>;
+ setTouched: Mock<(key: keyof FormValues, touched: boolean) => void>;
+ getValues: Mock<() => Partial>;
+ isSubmitting: boolean;
+ isValid: boolean;
+}
+
+// Mock context value
+const createMockContext = (): MockContext => ({
+ getValue: vi.fn((_key: keyof FormValues) => `value-${String(_key)}` as FieldValue),
+ setValue: vi.fn(),
+ getError: vi.fn((): string | null => null),
+ setError: vi.fn(),
+ getTouched: vi.fn(() => false),
+ setTouched: vi.fn(),
+ getValues: vi.fn((): Partial => ({ name: 'test' })),
+ isSubmitting: false,
+ isValid: true,
+});
+
+// Helper to cast mock context for FormKitProvider
+const createWrapper =
+ (mockContext: MockContext) =>
+ ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+describe('useFormKitContext', () => {
+ it('returns context value when used within FormKitProvider', () => {
+ const mockContext = createMockContext();
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ expect(result.current).toBe(mockContext);
+ });
+
+ it('throws error when used outside FormKitProvider', () => {
+ // Suppress console.error for this test
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ renderHook(() => useFormKitContext());
+ }).toThrow('useFormKitContext must be used within a DynamicForm');
+
+ consoleSpy.mockRestore();
+ });
+
+ it('provides getValue function', () => {
+ const mockContext = createMockContext();
+ mockContext.getValue.mockReturnValue('test-value');
+
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ expect(result.current.getValue('name')).toBe('test-value');
+ expect(mockContext.getValue).toHaveBeenCalledWith('name');
+ });
+
+ it('provides setValue function', () => {
+ const mockContext = createMockContext();
+
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ act(() => {
+ result.current.setValue('name', 'new-value');
+ });
+
+ expect(mockContext.setValue).toHaveBeenCalledWith('name', 'new-value');
+ });
+
+ it('provides getError function', () => {
+ const mockContext = createMockContext();
+ mockContext.getError.mockReturnValue('Field is required');
+
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ expect(result.current.getError('name')).toBe('Field is required');
+ });
+
+ it('provides setError function', () => {
+ const mockContext = createMockContext();
+
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ act(() => {
+ result.current.setError('name', 'Custom error');
+ });
+
+ expect(mockContext.setError).toHaveBeenCalledWith('name', 'Custom error');
+ });
+
+ it('provides getTouched and setTouched functions', () => {
+ const mockContext = createMockContext();
+ mockContext.getTouched.mockReturnValue(true);
+
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ expect(result.current.getTouched('name')).toBe(true);
+
+ act(() => {
+ result.current.setTouched('name', true);
+ });
+
+ expect(mockContext.setTouched).toHaveBeenCalledWith('name', true);
+ });
+
+ it('provides getValues function', () => {
+ const mockContext = createMockContext();
+ mockContext.getValues.mockReturnValue({ name: 'test', email: 'test@example.com' });
+
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ expect(result.current.getValues()).toEqual({ name: 'test', email: 'test@example.com' });
+ });
+
+ it('provides isSubmitting status', () => {
+ const mockContext = { ...createMockContext(), isSubmitting: true };
+
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ expect(result.current.isSubmitting).toBe(true);
+ });
+
+ it('provides isValid status', () => {
+ const mockContext = { ...createMockContext(), isValid: false };
+
+ const { result } = renderHook(() => useFormKitContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ expect(result.current.isValid).toBe(false);
+ });
+});
+
+describe('useFormContext (wrapper)', () => {
+ it('re-exports useFormKitContext', () => {
+ const mockContext = createMockContext();
+
+ const { result } = renderHook(() => useFormContext(), {
+ wrapper: createWrapper(mockContext),
+ });
+
+ expect(result.current).toBe(mockContext);
+ });
+
+ it('throws error when used outside FormKitProvider', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ renderHook(() => useFormContext());
+ }).toThrow();
+
+ consoleSpy.mockRestore();
+ });
+});
diff --git a/src/hooks/__tests__/useFormKit.test.ts b/src/hooks/__tests__/useFormKit.test.ts
new file mode 100644
index 0000000..f0f9ac1
--- /dev/null
+++ b/src/hooks/__tests__/useFormKit.test.ts
@@ -0,0 +1,467 @@
+/**
+ * Tests for hooks/useFormKit.ts
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { z } from 'zod';
+import { useFormKit } from '../useFormKit';
+
+describe('useFormKit', () => {
+ describe('initialization', () => {
+ it('initializes with default values', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: 'John', age: 25 },
+ }),
+ );
+
+ expect(result.current.values).toEqual({ name: 'John', age: 25 });
+ expect(result.current.errors).toEqual({});
+ expect(result.current.touched).toEqual({});
+ expect(result.current.isSubmitting).toBe(false);
+ expect(result.current.isValid).toBe(true);
+ expect(result.current.isDirty).toBe(false);
+ });
+
+ it('initializes with empty default values', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: {},
+ }),
+ );
+
+ expect(result.current.values).toEqual({});
+ });
+ });
+
+ describe('getValue', () => {
+ it('returns value for existing field', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: 'John' },
+ }),
+ );
+
+ expect(result.current.getValue('name')).toBe('John');
+ });
+
+ it('returns undefined for non-existent field', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: 'John' },
+ }),
+ );
+
+ expect(result.current.getValue('email' as 'name')).toBeUndefined();
+ });
+ });
+
+ describe('setValue', () => {
+ it('updates field value', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: '' },
+ }),
+ );
+
+ act(() => {
+ result.current.setValue('name', 'Jane');
+ });
+
+ expect(result.current.values.name).toBe('Jane');
+ });
+
+ it('clears error when value changes', () => {
+ const schema = z.object({ name: z.string().min(2) });
+ const { result } = renderHook(() =>
+ useFormKit({
+ schema,
+ defaultValues: { name: '' },
+ }),
+ );
+
+ // Trigger validation to create error
+ act(() => {
+ result.current.setError('name', 'Name too short');
+ });
+
+ expect(result.current.errors.name).toBe('Name too short');
+
+ // Change value should clear error
+ act(() => {
+ result.current.setValue('name', 'John');
+ });
+
+ expect(result.current.errors.name).toBeUndefined();
+ });
+
+ it('marks form as dirty', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: 'Initial' },
+ }),
+ );
+
+ expect(result.current.isDirty).toBe(false);
+
+ act(() => {
+ result.current.setValue('name', 'Changed');
+ });
+
+ expect(result.current.isDirty).toBe(true);
+ });
+ });
+
+ describe('getError / setError', () => {
+ it('returns null for field without error', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: '' },
+ }),
+ );
+
+ expect(result.current.getError('name')).toBeNull();
+ });
+
+ it('sets and retrieves error', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { email: '' },
+ }),
+ );
+
+ act(() => {
+ result.current.setError('email', 'Invalid email');
+ });
+
+ expect(result.current.getError('email')).toBe('Invalid email');
+ expect(result.current.isValid).toBe(false);
+ });
+
+ it('clears error when set to null', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { email: '' },
+ }),
+ );
+
+ act(() => {
+ result.current.setError('email', 'Invalid email');
+ });
+
+ act(() => {
+ result.current.setError('email', null);
+ });
+
+ expect(result.current.getError('email')).toBeNull();
+ expect(result.current.isValid).toBe(true);
+ });
+ });
+
+ describe('getTouched / setTouched', () => {
+ it('returns false for untouched field', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: '' },
+ }),
+ );
+
+ expect(result.current.getTouched('name')).toBe(false);
+ });
+
+ it('sets and retrieves touched state', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: '' },
+ }),
+ );
+
+ act(() => {
+ result.current.setTouched('name', true);
+ });
+
+ expect(result.current.getTouched('name')).toBe(true);
+ });
+ });
+
+ describe('getValues / setValues', () => {
+ it('getValues returns all values', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { a: 1, b: 2 },
+ }),
+ );
+
+ expect(result.current.getValues()).toEqual({ a: 1, b: 2 });
+ });
+
+ it('setValues updates multiple fields', () => {
+ const { result } = renderHook(() =>
+ useFormKit<{ name: string; email: string }>({
+ defaultValues: { name: '', email: '' },
+ }),
+ );
+
+ act(() => {
+ result.current.setValues({ name: 'John', email: 'john@test.com' });
+ });
+
+ expect(result.current.values).toEqual({ name: 'John', email: 'john@test.com' });
+ });
+ });
+
+ describe('validate', () => {
+ it('returns true when no schema', async () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: '' },
+ }),
+ );
+
+ let isValid: boolean;
+ await act(async () => {
+ isValid = await result.current.validate();
+ });
+
+ expect(isValid!).toBe(true);
+ });
+
+ it('returns true for valid data', async () => {
+ const schema = z.object({
+ name: z.string().min(2),
+ email: z.string().email(),
+ });
+
+ const { result } = renderHook(() =>
+ useFormKit({
+ schema,
+ defaultValues: { name: 'John', email: 'john@test.com' },
+ }),
+ );
+
+ let isValid: boolean;
+ await act(async () => {
+ isValid = await result.current.validate();
+ });
+
+ expect(isValid!).toBe(true);
+ expect(result.current.errors).toEqual({});
+ });
+
+ it('returns false and sets errors for invalid data', async () => {
+ const schema = z.object({
+ name: z.string().min(2, 'Name too short'),
+ email: z.string().email('Invalid email'),
+ });
+
+ const { result } = renderHook(() =>
+ useFormKit({
+ schema,
+ defaultValues: { name: 'A', email: 'invalid' },
+ }),
+ );
+
+ let isValid: boolean;
+ await act(async () => {
+ isValid = await result.current.validate();
+ });
+
+ expect(isValid!).toBe(false);
+ expect(result.current.errors.name).toBe('Name too short');
+ expect(result.current.errors.email).toBe('Invalid email');
+ });
+ });
+
+ describe('validateField', () => {
+ it('returns null when no schema', async () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: '' },
+ }),
+ );
+
+ let error: string | null;
+ await act(async () => {
+ error = await result.current.validateField('name');
+ });
+
+ expect(error!).toBeNull();
+ });
+
+ it('validates single field', async () => {
+ const schema = z.object({
+ name: z.string().min(2, 'Name too short'),
+ });
+
+ const { result } = renderHook(() =>
+ useFormKit({
+ schema,
+ defaultValues: { name: 'A' },
+ }),
+ );
+
+ let error: string | null;
+ await act(async () => {
+ error = await result.current.validateField('name');
+ });
+
+ expect(error!).toBe('Name too short');
+ });
+ });
+
+ describe('reset', () => {
+ it('resets values to defaults', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: 'Initial' },
+ }),
+ );
+
+ act(() => {
+ result.current.setValue('name', 'Changed');
+ });
+
+ expect(result.current.values.name).toBe('Changed');
+
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(result.current.values.name).toBe('Initial');
+ });
+
+ it('clears errors on reset', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: '' },
+ }),
+ );
+
+ act(() => {
+ result.current.setError('name', 'Error');
+ });
+
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(result.current.errors).toEqual({});
+ });
+
+ it('clears touched on reset', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: '' },
+ }),
+ );
+
+ act(() => {
+ result.current.setTouched('name', true);
+ });
+
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(result.current.touched).toEqual({});
+ });
+ });
+
+ describe('handleSubmit', () => {
+ it('calls onSubmit with valid data', async () => {
+ const schema = z.object({ name: z.string().min(2) });
+ const onSubmit = vi.fn();
+
+ const { result } = renderHook(() =>
+ useFormKit({
+ schema,
+ defaultValues: { name: 'John' },
+ }),
+ );
+
+ const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
+
+ await act(async () => {
+ await result.current.handleSubmit(onSubmit)(mockEvent);
+ });
+
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
+ expect(onSubmit).toHaveBeenCalledWith({ name: 'John' });
+ });
+
+ it('calls onError when validation fails', async () => {
+ const schema = z.object({
+ name: z.string().min(2, 'Name too short'),
+ });
+ const onSubmit = vi.fn();
+ const onError = vi.fn();
+
+ const { result } = renderHook(() =>
+ useFormKit({
+ schema,
+ defaultValues: { name: 'A' },
+ }),
+ );
+
+ const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
+
+ await act(async () => {
+ await result.current.handleSubmit(onSubmit, onError)(mockEvent);
+ });
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledWith({ name: 'Name too short' });
+ });
+
+ it('sets isSubmitting during async submission', async () => {
+ const schema = z.object({ name: z.string() });
+ let submitResolve: () => void;
+ const submitPromise = new Promise((resolve) => {
+ submitResolve = resolve;
+ });
+
+ const onSubmit = vi.fn().mockReturnValue(submitPromise);
+
+ const { result } = renderHook(() =>
+ useFormKit({
+ schema,
+ defaultValues: { name: 'Test' },
+ }),
+ );
+
+ const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
+
+ let submitAction: Promise;
+ act(() => {
+ submitAction = result.current.handleSubmit(onSubmit)(mockEvent);
+ });
+
+ // isSubmitting should be true during submission
+ expect(result.current.isSubmitting).toBe(true);
+
+ await act(async () => {
+ submitResolve!();
+ await submitAction!;
+ });
+
+ expect(result.current.isSubmitting).toBe(false);
+ });
+ });
+
+ describe('context', () => {
+ it('provides context value for FormKitProvider', () => {
+ const { result } = renderHook(() =>
+ useFormKit({
+ defaultValues: { name: 'Test' },
+ }),
+ );
+
+ expect(result.current.context).toBeDefined();
+ expect(result.current.context.getValue).toBe(result.current.getValue);
+ expect(result.current.context.setValue).toBe(result.current.setValue);
+ expect(result.current.context.getError).toBe(result.current.getError);
+ expect(result.current.context.getValues).toBe(result.current.getValues);
+ });
+ });
+});
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 6a94ddd..d4237a4 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1,4 +1,31 @@
-// Example placeholder export β replace with real hooks later.
-export const __hooks_placeholder = true;
+/**
+ * Hooks module - all form state and logic hooks
+ * Following CHM architecture: Hooks handle state and side effects
+ */
-export * from './useNoop';
+// Master form state hook
+export { useFormKit, type UseFormKitOptions, type UseFormKitReturn } from './useFormKit';
+
+// Context access hook
+export { useFormContext } from './useFormContext';
+
+// Field array management
+export {
+ useFieldArray,
+ type UseFieldArrayOptions,
+ type UseFieldArrayReturn,
+} from './useFieldArray';
+
+// Multi-step form navigation
+export { useFormStep, type UseFormStepOptions, type UseFormStepReturn } from './useFormStep';
+
+// Async validation with AbortController
+export {
+ useAsyncValidation,
+ type UseAsyncValidationOptions,
+ type UseAsyncValidationReturn,
+ type AsyncValidatorFn,
+} from './useAsyncValidation';
+
+// Internationalization hook
+export { useI18n, type I18nContextValue } from './useI18n';
diff --git a/src/hooks/useAsyncValidation.ts b/src/hooks/useAsyncValidation.ts
new file mode 100644
index 0000000..69d5577
--- /dev/null
+++ b/src/hooks/useAsyncValidation.ts
@@ -0,0 +1,161 @@
+/**
+ * useAsyncValidation - Debounced async field validation with AbortController
+ */
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { DEFAULT_DEBOUNCE_MS } from '../core/types';
+
+/**
+ * Async validator function signature
+ */
+export type AsyncValidatorFn = (
+ value: TValue,
+ ctx: { signal: AbortSignal },
+) => Promise;
+
+/**
+ * Options for useAsyncValidation hook
+ */
+export interface UseAsyncValidationOptions {
+ /** The async validator function */
+ validate: AsyncValidatorFn;
+ /** Current field value */
+ value: TValue;
+ /** Debounce delay in ms (default: 300) */
+ debounceMs?: number;
+ /** Whether validation is enabled */
+ enabled?: boolean;
+}
+
+/**
+ * Return type for useAsyncValidation hook
+ */
+export interface UseAsyncValidationReturn {
+ /** Current validation error (null if valid) */
+ error: string | null;
+ /** Whether validation is in progress */
+ isValidating: boolean;
+ /** Manually trigger validation */
+ triggerValidation: () => void;
+}
+
+/**
+ * Hook for debounced async field validation
+ * Automatically cancels in-flight requests on value change or unmount
+ *
+ * @param options - Async validation configuration
+ * @returns Validation state and methods
+ *
+ * @example
+ * ```tsx
+ * const { error, isValidating } = useAsyncValidation({
+ * validate: async (email, { signal }) => {
+ * const exists = await checkEmailExists(email, signal);
+ * return exists ? 'Email already taken' : null;
+ * },
+ * value: email,
+ * debounceMs: 400,
+ * });
+ * ```
+ */
+export function useAsyncValidation(
+ options: UseAsyncValidationOptions,
+): UseAsyncValidationReturn {
+ const { validate, value, debounceMs = DEFAULT_DEBOUNCE_MS, enabled = true } = options;
+
+ const [error, setError] = useState(null);
+ const [isValidating, setIsValidating] = useState(false);
+
+ // Refs for cleanup
+ const abortControllerRef = useRef(null);
+ const timerRef = useRef | null>(null);
+
+ // Cleanup function
+ const cleanup = useCallback(() => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+ }, []);
+
+ // Run validation
+ const runValidation = useCallback(async () => {
+ // Abort any existing request
+ cleanup();
+
+ if (!enabled) {
+ return;
+ }
+
+ // Create new AbortController
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ setIsValidating(true);
+
+ try {
+ const result = await validate(value, { signal: controller.signal });
+
+ // Only update if not aborted
+ if (!controller.signal.aborted) {
+ setError(result);
+ setIsValidating(false);
+ }
+ } catch (err) {
+ // Ignore abort errors (handle both Error instances and DOMException)
+ if (
+ (err instanceof Error && err.name === 'AbortError') ||
+ (err instanceof DOMException && err.name === 'AbortError')
+ ) {
+ return;
+ }
+
+ // Only update if not aborted
+ if (!controller.signal.aborted) {
+ setError('Validation failed');
+ setIsValidating(false);
+ }
+ }
+ }, [cleanup, enabled, validate, value]);
+
+ // Debounced validation on value change
+ useEffect(() => {
+ if (!enabled) {
+ // Skip validation when disabled - state reset handled by separate effect
+ return;
+ }
+
+ // Clear existing timer
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ }
+
+ // Set new timer
+ timerRef.current = setTimeout(runValidation, debounceMs);
+
+ // Cleanup on unmount or value change
+ return cleanup;
+ }, [value, enabled, debounceMs, runValidation, cleanup]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return cleanup;
+ }, [cleanup]);
+
+ // Manual trigger
+ const triggerValidation = useCallback(() => {
+ cleanup();
+ runValidation();
+ }, [cleanup, runValidation]);
+
+ // Use derived state: when disabled, always return null/false
+ return {
+ error: enabled ? error : null,
+ isValidating: enabled ? isValidating : false,
+ triggerValidation,
+ };
+}
diff --git a/src/hooks/useFieldArray.ts b/src/hooks/useFieldArray.ts
new file mode 100644
index 0000000..427dd31
--- /dev/null
+++ b/src/hooks/useFieldArray.ts
@@ -0,0 +1,178 @@
+/**
+ * useFieldArray - Repeatable field group management
+ */
+
+import { useCallback, useMemo } from 'react';
+import type { FieldValue } from '../core/types';
+import { useFormKitContext } from './FormKitContext';
+
+/**
+ * Options for useFieldArray hook
+ */
+export interface UseFieldArrayOptions {
+ /** Field key that contains the array */
+ name: string;
+ /** Minimum number of rows */
+ minRows?: number;
+ /** Maximum number of rows */
+ maxRows?: number;
+}
+
+/**
+ * Return type for useFieldArray hook
+ */
+export interface UseFieldArrayReturn> {
+ /** Current array of rows */
+ fields: TRow[];
+ /** Number of rows */
+ count: number;
+ /** Whether more rows can be added */
+ canAdd: boolean;
+ /** Whether rows can be removed */
+ canRemove: boolean;
+
+ /** Append a new row to the end */
+ append: (value: TRow) => void;
+ /** Prepend a new row to the beginning */
+ prepend: (value: TRow) => void;
+ /** Insert a row at a specific index */
+ insert: (index: number, value: TRow) => void;
+ /** Remove a row at a specific index */
+ remove: (index: number) => void;
+ /** Move a row from one index to another */
+ move: (from: number, to: number) => void;
+ /** Swap two rows */
+ swap: (indexA: number, indexB: number) => void;
+ /** Update a row at a specific index */
+ update: (index: number, value: TRow) => void;
+ /** Replace entire array */
+ replace: (values: TRow[]) => void;
+}
+
+/**
+ * Hook for managing repeatable field groups (arrays)
+ * Used internally by ArrayField component
+ *
+ * @param options - Field array configuration
+ * @returns Field array state and methods
+ *
+ * @example
+ * ```tsx
+ * const { fields, append, remove, canAdd, canRemove } = useFieldArray({
+ * name: 'contacts',
+ * minRows: 1,
+ * maxRows: 5,
+ * });
+ * ```
+ */
+export function useFieldArray>(
+ options: UseFieldArrayOptions,
+): UseFieldArrayReturn {
+ const { name, minRows = 0, maxRows = Infinity } = options;
+ const { getValue, setValue } = useFormKitContext();
+
+ // Get current array value
+ const fields = useMemo(() => {
+ const value = getValue(name);
+ return (Array.isArray(value) ? value : []) as TRow[];
+ }, [getValue, name]);
+
+ const count = fields.length;
+ const canAdd = count < maxRows;
+ const canRemove = count > minRows;
+
+ // Append row
+ const append = useCallback(
+ (value: TRow) => {
+ if (!canAdd) return;
+ setValue(name, [...fields, value] as FieldValue);
+ },
+ [canAdd, fields, name, setValue],
+ );
+
+ // Prepend row
+ const prepend = useCallback(
+ (value: TRow) => {
+ if (!canAdd) return;
+ setValue(name, [value, ...fields] as FieldValue);
+ },
+ [canAdd, fields, name, setValue],
+ );
+
+ // Insert at index
+ const insert = useCallback(
+ (index: number, value: TRow) => {
+ if (!canAdd) return;
+ const newFields = [...fields];
+ newFields.splice(index, 0, value);
+ setValue(name, newFields as FieldValue);
+ },
+ [canAdd, fields, name, setValue],
+ );
+
+ // Remove at index
+ const remove = useCallback(
+ (index: number) => {
+ if (!canRemove) return;
+ setValue(name, fields.filter((_, i) => i !== index) as FieldValue);
+ },
+ [canRemove, fields, name, setValue],
+ );
+
+ // Move row
+ const move = useCallback(
+ (from: number, to: number) => {
+ if (from < 0 || from >= count || to < 0 || to >= count) return;
+ const newFields = [...fields];
+ const [removed] = newFields.splice(from, 1);
+ newFields.splice(to, 0, removed);
+ setValue(name, newFields as FieldValue);
+ },
+ [count, fields, name, setValue],
+ );
+
+ // Swap rows
+ const swap = useCallback(
+ (indexA: number, indexB: number) => {
+ if (indexA < 0 || indexA >= count || indexB < 0 || indexB >= count) return;
+ const newFields = [...fields];
+ [newFields[indexA], newFields[indexB]] = [newFields[indexB], newFields[indexA]];
+ setValue(name, newFields as FieldValue);
+ },
+ [count, fields, name, setValue],
+ );
+
+ // Update row
+ const update = useCallback(
+ (index: number, value: TRow) => {
+ if (index < 0 || index >= count) return;
+ const newFields = [...fields];
+ newFields[index] = value;
+ setValue(name, newFields as FieldValue);
+ },
+ [count, fields, name, setValue],
+ );
+
+ // Replace all
+ const replace = useCallback(
+ (values: TRow[]) => {
+ setValue(name, values as FieldValue);
+ },
+ [name, setValue],
+ );
+
+ return {
+ fields,
+ count,
+ canAdd,
+ canRemove,
+ append,
+ prepend,
+ insert,
+ remove,
+ move,
+ swap,
+ update,
+ replace,
+ };
+}
diff --git a/src/hooks/useFormContext.ts b/src/hooks/useFormContext.ts
new file mode 100644
index 0000000..d6775d2
--- /dev/null
+++ b/src/hooks/useFormContext.ts
@@ -0,0 +1,29 @@
+/**
+ * useFormContext - Access form state from deep in the component tree
+ * Re-exports useFormKitContext for public API
+ */
+
+import { useFormKitContext } from './FormKitContext';
+import type { FormContextValue } from '../models/FormState';
+import type { FormValues } from '../core/types';
+
+/**
+ * Hook to access form context from any component inside DynamicForm
+ * Throws if used outside of DynamicForm
+ *
+ * @returns Form context value with getValue, setValue, getError, etc.
+ *
+ * @example
+ * ```tsx
+ * function CustomField() {
+ * const { getValue, setValue, getError } = useFormContext();
+ * const value = getValue('email');
+ * // ...
+ * }
+ * ```
+ */
+export function useFormContext<
+ TValues extends FormValues = FormValues,
+>(): FormContextValue {
+ return useFormKitContext();
+}
diff --git a/src/hooks/useFormKit.ts b/src/hooks/useFormKit.ts
new file mode 100644
index 0000000..cb9163c
--- /dev/null
+++ b/src/hooks/useFormKit.ts
@@ -0,0 +1,307 @@
+/**
+ * useFormKit - Master form state management hook
+ * Internal hook powering DynamicForm
+ */
+
+import { useState, useCallback, useMemo, useRef } from 'react';
+import type { z } from 'zod';
+import type { FormValues, FieldValue, ValidationMode } from '../core/types';
+import type { FieldErrors } from '../core/validator';
+import type { FormContextValue } from '../models/FormState';
+import { mapZodErrors } from '../core/validator';
+
+/**
+ * Options for useFormKit hook
+ */
+export interface UseFormKitOptions {
+ /** Zod schema for validation */
+ schema?: z.ZodType;
+ /** Initial/default values */
+ defaultValues: Partial;
+ /** Validation mode */
+ mode?: ValidationMode;
+}
+
+/**
+ * Return type for useFormKit hook
+ */
+export interface UseFormKitReturn {
+ /** Current form values */
+ values: Partial;
+ /** Field errors */
+ errors: FieldErrors;
+ /** Touched fields */
+ touched: Partial>;
+ /** Whether form is submitting */
+ isSubmitting: boolean;
+ /** Whether form is valid (no errors) */
+ isValid: boolean;
+ /** Whether any field has changed from initial */
+ isDirty: boolean;
+
+ /** Get value for a field */
+ getValue: (key: keyof TValues) => FieldValue;
+ /** Set value for a field */
+ setValue: (key: keyof TValues, value: FieldValue) => void;
+ /** Get error for a field */
+ getError: (key: keyof TValues) => string | null;
+ /** Set error for a field */
+ setError: (key: keyof TValues, error: string | null) => void;
+ /** Get touched state for a field */
+ getTouched: (key: keyof TValues) => boolean;
+ /** Set touched state for a field */
+ setTouched: (key: keyof TValues, touched: boolean) => void;
+ /** Get all values */
+ getValues: () => Partial;
+ /** Set multiple values at once */
+ setValues: (values: Partial) => void;
+
+ /** Validate entire form */
+ validate: () => Promise;
+ /** Validate a single field */
+ validateField: (key: keyof TValues) => Promise;
+ /** Reset form to default values */
+ reset: () => void;
+
+ /** Handle form submission */
+ handleSubmit: (
+ onSubmit: (values: TValues) => Promise | void,
+ onError?: (errors: FieldErrors) => void,
+ ) => (e: React.FormEvent) => Promise;
+
+ /** Context value for FormKitProvider */
+ context: FormContextValue;
+}
+
+/**
+ * Master form state management hook
+ * Powers DynamicForm internally - not typically used directly
+ *
+ * @param options - Form configuration options
+ * @returns Form state and methods
+ *
+ * @example
+ * ```tsx
+ * const form = useFormKit({
+ * schema: z.object({ name: z.string().min(2) }),
+ * defaultValues: { name: '' },
+ * mode: 'onBlur',
+ * });
+ * ```
+ */
+export function useFormKit(
+ options: UseFormKitOptions,
+): UseFormKitReturn {
+ const { schema, defaultValues } = options;
+
+ // State
+ const [values, setValuesState] = useState>(defaultValues);
+ const [errors, setErrors] = useState>({});
+ const [touched, setTouched] = useState>>({});
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // Refs for stable callbacks
+ const valuesRef = useRef(values);
+ valuesRef.current = values;
+
+ // Computed state
+ const isValid = useMemo(() => Object.keys(errors).length === 0, [errors]);
+ const isDirty = useMemo(() => {
+ return Object.keys(values).some(
+ (key) => values[key as keyof TValues] !== defaultValues[key as keyof TValues],
+ );
+ }, [values, defaultValues]);
+
+ // Get single value
+ const getValue = useCallback((key: keyof TValues): FieldValue => {
+ return valuesRef.current[key];
+ }, []);
+
+ // Set single value
+ const setValue = useCallback((key: keyof TValues, value: FieldValue) => {
+ setValuesState((prev) => ({ ...prev, [key]: value }));
+ // Clear error on change
+ setErrors((prev) => {
+ if (prev[key]) {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ }
+ return prev;
+ });
+ }, []);
+
+ // Get error
+ const getError = useCallback(
+ (key: keyof TValues): string | null => {
+ return errors[key] ?? null;
+ },
+ [errors],
+ );
+
+ // Set error
+ const setError = useCallback((key: keyof TValues, error: string | null) => {
+ setErrors((prev) => {
+ if (error === null) {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ }
+ return { ...prev, [key]: error };
+ });
+ }, []);
+
+ // Get touched
+ const getIsTouched = useCallback(
+ (key: keyof TValues): boolean => {
+ return touched[key] ?? false;
+ },
+ [touched],
+ );
+
+ // Set touched
+ const setIsTouched = useCallback((key: keyof TValues, isTouched: boolean) => {
+ setTouched((prev) => ({ ...prev, [key]: isTouched }));
+ }, []);
+
+ // Get all values
+ const getValues = useCallback((): Partial => {
+ return valuesRef.current;
+ }, []);
+
+ // Set multiple values
+ const setValues = useCallback((newValues: Partial) => {
+ setValuesState((prev) => ({ ...prev, ...newValues }));
+ }, []);
+
+ // Validate entire form - returns errors for immediate use
+ const validate = useCallback(async (): Promise<{
+ isValid: boolean;
+ errors: FieldErrors;
+ }> => {
+ if (!schema) return { isValid: true, errors: {} };
+
+ const result = schema.safeParse(valuesRef.current);
+ if (result.success) {
+ setErrors({});
+ return { isValid: true, errors: {} };
+ }
+
+ const fieldErrors = mapZodErrors(result.error);
+ setErrors(fieldErrors);
+ return { isValid: false, errors: fieldErrors };
+ }, [schema]);
+
+ // Validate single field
+ const validateField = useCallback(
+ async (key: keyof TValues): Promise => {
+ if (!schema) return null;
+
+ // For per-field validation, we validate the whole form but only return the error for this field
+ const result = schema.safeParse(valuesRef.current);
+ if (result.success) {
+ setError(key, null);
+ return null;
+ }
+
+ const fieldErrors = mapZodErrors(result.error);
+ const error = fieldErrors[key] ?? null;
+ setError(key, error);
+ return error;
+ },
+ [schema, setError],
+ );
+
+ // Reset form
+ const reset = useCallback(() => {
+ setValuesState(defaultValues);
+ setErrors({});
+ setTouched({});
+ }, [defaultValues]);
+
+ // Handle submit
+ const handleSubmit = useCallback(
+ (
+ onSubmit: (values: TValues) => Promise | void,
+ onError?: (errors: FieldErrors) => void,
+ ) => {
+ return async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+
+ try {
+ const { isValid, errors: validationErrors } = await validate();
+ if (!isValid) {
+ // Mark all fields with errors as touched so errors display
+ const touchedFields: Partial> = {};
+ Object.keys(validationErrors).forEach((key) => {
+ touchedFields[key as keyof TValues] = true;
+ });
+ setTouched((prev) => ({ ...prev, ...touchedFields }));
+ onError?.(validationErrors);
+ return;
+ }
+
+ await onSubmit(valuesRef.current as TValues);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+ },
+ [validate],
+ );
+
+ // Context value for FormKitProvider
+ const context = useMemo>(
+ () => ({
+ getValue,
+ setValue,
+ getError,
+ setError,
+ getTouched: getIsTouched,
+ setTouched: setIsTouched,
+ getValues,
+ isSubmitting,
+ isValid,
+ }),
+ [
+ getValue,
+ setValue,
+ getError,
+ setError,
+ getIsTouched,
+ setIsTouched,
+ getValues,
+ isSubmitting,
+ isValid,
+ ],
+ );
+
+ // Public validate that returns boolean for backward compatibility
+ const validatePublic = useCallback(async (): Promise => {
+ const { isValid } = await validate();
+ return isValid;
+ }, [validate]);
+
+ return {
+ values,
+ errors,
+ touched,
+ isSubmitting,
+ isValid,
+ isDirty,
+ getValue,
+ setValue,
+ getError,
+ setError,
+ getTouched: getIsTouched,
+ setTouched: setIsTouched,
+ getValues,
+ setValues,
+ validate: validatePublic,
+ validateField,
+ reset,
+ handleSubmit,
+ context,
+ };
+}
diff --git a/src/hooks/useFormStep.ts b/src/hooks/useFormStep.ts
new file mode 100644
index 0000000..95420c4
--- /dev/null
+++ b/src/hooks/useFormStep.ts
@@ -0,0 +1,160 @@
+/**
+ * useFormStep - Multi-step form navigation
+ */
+
+import { useState, useCallback } from 'react';
+import type { StepConfig } from '../models/StepConfig';
+import type { FormValues } from '../core/types';
+import type { FieldErrors } from '../core/validator';
+import { mapZodErrors } from '../core/validator';
+
+/**
+ * Options for useFormStep hook
+ */
+export interface UseFormStepOptions {
+ /** Step configurations */
+ steps: StepConfig[];
+ /** Current form values (for validation) */
+ values: FormValues;
+ /** Callback when step changes */
+ onStepChange?: (step: number) => void;
+}
+
+/**
+ * Return type for useFormStep hook
+ */
+export interface UseFormStepReturn {
+ /** Current step index (0-based) */
+ currentStep: number;
+ /** Total number of steps */
+ totalSteps: number;
+ /** Current step configuration */
+ currentStepConfig: StepConfig | undefined;
+ /** Whether on first step */
+ isFirstStep: boolean;
+ /** Whether on last step */
+ isLastStep: boolean;
+ /** Progress percentage (0-100) */
+ progress: number;
+
+ /** Go to next step (validates current step first) */
+ next: () => Promise;
+ /** Go to previous step */
+ prev: () => void;
+ /** Go to specific step (validates if moving forward) */
+ goTo: (step: number) => Promise;
+ /** Reset to first step */
+ reset: () => void;
+
+ /** Validation errors for current step */
+ stepErrors: FieldErrors;
+}
+
+/**
+ * Hook for managing multi-step form navigation
+ * Used internally by DynamicForm in wizard mode
+ *
+ * @param options - Step configuration options
+ * @returns Step navigation state and methods
+ *
+ * @example
+ * ```tsx
+ * const { currentStep, next, prev, isLastStep } = useFormStep({
+ * steps,
+ * values: formValues,
+ * });
+ * ```
+ */
+export function useFormStep(options: UseFormStepOptions): UseFormStepReturn {
+ const { steps, values, onStepChange } = options;
+
+ const [currentStep, setCurrentStep] = useState(0);
+ const [stepErrors, setStepErrors] = useState({});
+
+ const totalSteps = steps.length;
+ const currentStepConfig = steps[currentStep];
+ const isFirstStep = currentStep === 0;
+ const isLastStep = currentStep === totalSteps - 1;
+ const progress = totalSteps > 0 ? ((currentStep + 1) / totalSteps) * 100 : 0;
+
+ // Validate current step
+ const validateCurrentStep = useCallback(async (): Promise => {
+ const stepConfig = steps[currentStep];
+ if (!stepConfig?.schema) {
+ setStepErrors({});
+ return true;
+ }
+
+ const result = stepConfig.schema.safeParse(values);
+ if (result.success) {
+ setStepErrors({});
+ return true;
+ }
+
+ setStepErrors(mapZodErrors(result.error));
+ return false;
+ }, [currentStep, steps, values]);
+
+ // Go to next step
+ const next = useCallback(async (): Promise => {
+ if (isLastStep) return false;
+
+ const isValid = await validateCurrentStep();
+ if (!isValid) return false;
+
+ const nextStep = currentStep + 1;
+ setCurrentStep(nextStep);
+ onStepChange?.(nextStep);
+ return true;
+ }, [isLastStep, validateCurrentStep, currentStep, onStepChange]);
+
+ // Go to previous step
+ const prev = useCallback(() => {
+ if (isFirstStep) return;
+
+ const prevStep = currentStep - 1;
+ setCurrentStep(prevStep);
+ setStepErrors({});
+ onStepChange?.(prevStep);
+ }, [isFirstStep, currentStep, onStepChange]);
+
+ // Go to specific step
+ const goTo = useCallback(
+ async (step: number): Promise => {
+ if (step < 0 || step >= totalSteps) return false;
+
+ // If moving forward, validate current step
+ if (step > currentStep) {
+ const isValid = await validateCurrentStep();
+ if (!isValid) return false;
+ }
+
+ setCurrentStep(step);
+ setStepErrors({});
+ onStepChange?.(step);
+ return true;
+ },
+ [totalSteps, currentStep, validateCurrentStep, onStepChange],
+ );
+
+ // Reset to first step
+ const reset = useCallback(() => {
+ setCurrentStep(0);
+ setStepErrors({});
+ onStepChange?.(0);
+ }, [onStepChange]);
+
+ return {
+ currentStep,
+ totalSteps,
+ currentStepConfig,
+ isFirstStep,
+ isLastStep,
+ progress,
+ next,
+ prev,
+ goTo,
+ reset,
+ stepErrors,
+ };
+}
diff --git a/src/hooks/useI18n.ts b/src/hooks/useI18n.ts
new file mode 100644
index 0000000..2c6a2cc
--- /dev/null
+++ b/src/hooks/useI18n.ts
@@ -0,0 +1,25 @@
+/**
+ * useI18n hook - Access translations in components
+ */
+
+import { useContext } from 'react';
+import { I18nContext, type I18nContextValue } from '../components/context/I18nContext';
+
+/**
+ * Hook to access i18n translations
+ *
+ * @returns I18n context value with t() function and locale
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const { t, locale } = useI18n();
+ * return ;
+ * }
+ * ```
+ */
+export function useI18n(): I18nContextValue {
+ return useContext(I18nContext);
+}
+
+export type { I18nContextValue };
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/index.ts b/src/index.ts
index fc82a00..aa171d9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,44 @@
-export * from "./components";
-export * from "./hooks";
-export * from "./utils";
+// ββ Primary public surface ββββββββββββββββββββββββββββββββββββββ
+export { default as DynamicForm } from './components/form/DynamicForm';
+
+// ββ Types (consumers need for field config + step config) βββββββ
+export type { DynamicFormProps } from './components/form/DynamicForm';
+export type { FieldConfig, FieldOption } from './models/FieldConfig';
+export type { StepConfig } from './models/StepConfig';
+export type {
+ SectionConfig,
+ FormLayoutItem,
+ ResponsiveColumns,
+ ResponsiveColSpan,
+ ColumnCount,
+ ColSpanValue,
+} from './models/SectionConfig';
+export { isSection, isField } from './models/SectionConfig';
+export type { FormState, FieldState, FormContextValue } from './models/FormState';
+export type {
+ ValidationRule,
+ AsyncValidationRule,
+ SyncValidationRule,
+} from './models/ValidationRule';
+
+// ββ Enum (consumers reference when building field configs) βββββββ
+export { FieldType } from './core/types';
+
+// ββ Core types βββββββββββββββββββββββββββββββββββββββββββββββββββ
+export type { ConditionalRule, FormValues, FieldValue, ValidationMode } from './core/types';
+
+// ββ i18n (internationalization) βββββββββββββββββββββββββββββββββ
+export { default as I18nProvider } from './components/context/I18nContext';
+export type { I18nContextValue } from './components/context/I18nContext';
+export { useI18n } from './hooks/useI18n';
+export type { Locale, TranslationKeys } from './core/i18n';
+export { DEFAULT_LOCALE } from './core/i18n';
+export { default as en } from './locales/en';
+export { default as fr } from './locales/fr';
+
+// ββ Schema helpers (for building Zod schemas declaratively) βββββ
+export { createFieldSchema, mergeSchemas } from './core/schema-helpers';
+
+// ββ Advanced extensibility (exposed, but not primary API) ββββββββ
+export { useFormKit, type UseFormKitOptions, type UseFormKitReturn } from './hooks/useFormKit';
+export { useFormContext } from './hooks/useFormContext';
diff --git a/src/locales/en.ts b/src/locales/en.ts
new file mode 100644
index 0000000..cd8d9d9
--- /dev/null
+++ b/src/locales/en.ts
@@ -0,0 +1,161 @@
+/**
+ * English translations
+ */
+
+import type { TranslationKeys } from '../core/i18n';
+
+const en: TranslationKeys = {
+ // Form actions
+ form: {
+ submit: 'Submit',
+ reset: 'Reset',
+ next: 'Next',
+ back: 'Back',
+ confirm: 'Confirm',
+ submitting: 'Submitting...',
+ },
+
+ // Field actions
+ field: {
+ add: 'Add',
+ remove: 'Remove',
+ showPassword: 'Show password',
+ hidePassword: 'Hide password',
+ clearSelection: 'Clear selection',
+ search: 'Search...',
+ selectOption: 'Select an option...',
+ noOptions: 'No options available',
+ noOptionsFound: 'No options found',
+ loading: 'Loading...',
+ selected: 'selected',
+ typeAndEnter: 'Type and press Enter',
+ phoneNumber: 'Phone number',
+ yes: 'Yes',
+ no: 'No',
+ },
+
+ // Accessibility
+ a11y: {
+ formSteps: 'Form steps',
+ required: 'required',
+ stepCurrent: 'current',
+ stepCompleted: 'completed',
+ stepNumber: 'Step',
+ removeItem: 'Remove',
+ addItem: 'Add',
+ calendar: 'Calendar',
+ },
+
+ // Date/Time
+ datetime: {
+ months: {
+ january: 'January',
+ february: 'February',
+ march: 'March',
+ april: 'April',
+ may: 'May',
+ june: 'June',
+ july: 'July',
+ august: 'August',
+ september: 'September',
+ october: 'October',
+ november: 'November',
+ december: 'December',
+ },
+ monthsShort: {
+ jan: 'Jan',
+ feb: 'Feb',
+ mar: 'Mar',
+ apr: 'Apr',
+ may: 'May',
+ jun: 'Jun',
+ jul: 'Jul',
+ aug: 'Aug',
+ sep: 'Sep',
+ oct: 'Oct',
+ nov: 'Nov',
+ dec: 'Dec',
+ },
+ days: {
+ sunday: 'Sunday',
+ monday: 'Monday',
+ tuesday: 'Tuesday',
+ wednesday: 'Wednesday',
+ thursday: 'Thursday',
+ friday: 'Friday',
+ saturday: 'Saturday',
+ },
+ daysShort: {
+ sun: 'Sun',
+ mon: 'Mon',
+ tue: 'Tue',
+ wed: 'Wed',
+ thu: 'Thu',
+ fri: 'Fri',
+ sat: 'Sat',
+ },
+ am: 'AM',
+ pm: 'PM',
+ today: 'Today',
+ selectDate: 'Select date',
+ selectTime: 'Select time',
+ hour: 'Hour',
+ minute: 'Minute',
+ previousMonth: 'Previous month',
+ nextMonth: 'Next month',
+ dateLabel: 'Date',
+ timeLabel: 'Time',
+ },
+
+ // File upload
+ file: {
+ dragDrop: 'Drag and drop file here, or',
+ browse: 'browse',
+ remove: 'Remove',
+ maxSize: 'Max size:',
+ invalidType: 'is not an accepted file type',
+ exceedsMaxSize: 'exceeds max size of',
+ selected: 'Selected:',
+ accepted: 'Accepted:',
+ },
+
+ // Phone field
+ phone: {
+ searchCountry: 'Search country...',
+ selectCountry: 'Select country',
+ },
+
+ // Rating field
+ rating: {
+ stars: 'stars',
+ outOf: 'out of',
+ noRating: 'No rating',
+ },
+
+ // Tags field
+ tags: {
+ addTag: 'Add tag',
+ removeTag: 'Remove tag',
+ maxTags: 'Maximum tags reached',
+ },
+
+ // Array field
+ array: {
+ empty: 'No items yet. Add your first one!',
+ row: 'Item',
+ rowAdded: 'Item added',
+ rowRemoved: 'Item removed',
+ moveUp: 'Move up row',
+ moveDown: 'Move down row',
+ rowMovedUp: 'Item moved up',
+ rowMovedDown: 'Item moved down',
+ expand: 'Expand row',
+ collapse: 'Collapse row',
+ confirmRemove: 'Remove?',
+ minHint: 'At least {min} required',
+ maxHint: 'Maximum {max} allowed',
+ minMaxHint: 'Between {min} and {max} items',
+ },
+};
+
+export default en;
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
new file mode 100644
index 0000000..e76f85d
--- /dev/null
+++ b/src/locales/fr.ts
@@ -0,0 +1,160 @@
+/**
+ * French translations
+ */
+
+import type { TranslationKeys } from '../core/i18n';
+
+const fr: TranslationKeys = {
+ // Form actions
+ form: {
+ submit: 'Envoyer',
+ reset: 'RΓ©initialiser',
+ next: 'Suivant',
+ back: 'Retour',
+ confirm: 'Confirmer',
+ submitting: 'Envoi en cours...',
+ },
+
+ // Field actions
+ field: {
+ add: 'Ajouter',
+ remove: 'Supprimer',
+ showPassword: 'Afficher le mot de passe',
+ hidePassword: 'Masquer le mot de passe',
+ clearSelection: 'Effacer la sΓ©lection',
+ search: 'Rechercher...',
+ selectOption: 'SΓ©lectionner une option...',
+ noOptions: 'Aucune option disponible',
+ noOptionsFound: 'Aucune option trouvΓ©e',
+ loading: 'Chargement...',
+ selected: 'sΓ©lectionnΓ©(s)',
+ typeAndEnter: 'Tapez et appuyez sur EntrΓ©e',
+ phoneNumber: 'NumΓ©ro de tΓ©lΓ©phone',
+ yes: 'Oui',
+ no: 'Non',
+ },
+
+ // Accessibility
+ a11y: {
+ formSteps: 'Γtapes du formulaire',
+ required: 'requis',
+ stepCurrent: 'actuel',
+ stepCompleted: 'terminΓ©',
+ stepNumber: 'Γtape',
+ removeItem: 'Supprimer',
+ addItem: 'Ajouter',
+ calendar: 'Calendrier',
+ },
+
+ // Date/Time
+ datetime: {
+ months: {
+ january: 'Janvier',
+ february: 'FΓ©vrier',
+ march: 'Mars',
+ april: 'Avril',
+ may: 'Mai',
+ june: 'Juin',
+ july: 'Juillet',
+ august: 'AoΓ»t',
+ september: 'Septembre',
+ october: 'Octobre',
+ november: 'Novembre',
+ december: 'DΓ©cembre',
+ },
+ monthsShort: {
+ jan: 'Jan',
+ feb: 'FΓ©v',
+ mar: 'Mar',
+ apr: 'Avr',
+ may: 'Mai',
+ jun: 'Juin',
+ jul: 'Juil',
+ aug: 'AoΓ»',
+ sep: 'Sep',
+ oct: 'Oct',
+ nov: 'Nov',
+ dec: 'DΓ©c',
+ },
+ days: {
+ sunday: 'Dimanche',
+ monday: 'Lundi',
+ tuesday: 'Mardi',
+ wednesday: 'Mercredi',
+ thursday: 'Jeudi',
+ friday: 'Vendredi',
+ saturday: 'Samedi',
+ },
+ daysShort: {
+ sun: 'Dim',
+ mon: 'Lun',
+ tue: 'Mar',
+ wed: 'Mer',
+ thu: 'Jeu',
+ fri: 'Ven',
+ sat: 'Sam',
+ },
+ am: 'AM',
+ pm: 'PM',
+ today: "Aujourd'hui",
+ selectDate: 'SΓ©lectionner une date',
+ selectTime: "SΓ©lectionner l'heure",
+ hour: 'Heure',
+ minute: 'Minute',
+ previousMonth: 'Mois prΓ©cΓ©dent',
+ nextMonth: 'Mois suivant',
+ dateLabel: 'Date',
+ timeLabel: 'Heure',
+ },
+
+ // File upload
+ file: {
+ dragDrop: 'Glissez-dΓ©posez un fichier ici, ou',
+ browse: 'parcourir',
+ remove: 'Supprimer',
+ maxSize: 'Taille max :',
+ invalidType: "n'est pas un type de fichier acceptΓ©",
+ exceedsMaxSize: 'dΓ©passe la taille maximale de',
+ selected: 'SΓ©lectionnΓ© :',
+ accepted: 'AcceptΓ©s :',
+ },
+
+ // Phone field
+ phone: {
+ searchCountry: 'Rechercher un pays...',
+ selectCountry: 'SΓ©lectionner un pays',
+ },
+
+ // Rating field
+ rating: {
+ stars: 'Γ©toiles',
+ outOf: 'sur',
+ noRating: 'Aucune note',
+ },
+
+ // Tags field
+ tags: {
+ addTag: 'Ajouter un tag',
+ removeTag: 'Supprimer le tag',
+ maxTags: 'Nombre maximum de tags atteint',
+ },
+
+ // Array field
+ array: {
+ empty: 'Aucun Γ©lΓ©ment. Ajoutez le premier !',
+ row: 'ΓlΓ©ment',
+ rowAdded: 'ΓlΓ©ment ajoutΓ©',
+ rowRemoved: 'ΓlΓ©ment supprimΓ©',
+ moveUp: 'DΓ©placer vers le haut la ligne',
+ moveDown: 'DΓ©placer vers le bas la ligne',
+ rowMovedUp: 'ΓlΓ©ment dΓ©placΓ© vers le haut',
+ rowMovedDown: 'ΓlΓ©ment dΓ©placΓ© vers le bas',
+ expand: 'DΓ©velopper la ligne',
+ collapse: 'RΓ©duire la ligne',
+ confirmRemove: 'Supprimer ?',
+ minHint: 'Au moins {min} requis',
+ maxHint: 'Maximum {max} autorisΓ©s',
+ minMaxHint: 'Entre {min} et {max} Γ©lΓ©ments',
+ },
+};
+export default fr;
diff --git a/src/locales/index.ts b/src/locales/index.ts
new file mode 100644
index 0000000..5a1ebf0
--- /dev/null
+++ b/src/locales/index.ts
@@ -0,0 +1,6 @@
+/**
+ * Locale exports
+ */
+
+export { default as en } from './en';
+export { default as fr } from './fr';
diff --git a/src/models/FieldConfig.ts b/src/models/FieldConfig.ts
new file mode 100644
index 0000000..80f7264
--- /dev/null
+++ b/src/models/FieldConfig.ts
@@ -0,0 +1,149 @@
+/**
+ * FieldConfig - Per-field configuration for DynamicForm
+ * Type Layer β contracts only, zero runtime logic
+ */
+
+import type { FieldType, ConditionalRule, FieldValue } from '../core/types';
+import type { ColSpanValue } from './SectionConfig';
+
+/**
+ * Option for select, radio, or multi-select fields
+ */
+export interface FieldOption {
+ /** Option value */
+ readonly value: string | number;
+ /** Option display label */
+ readonly label: string;
+ /** Whether option is disabled */
+ readonly disabled?: boolean;
+}
+
+/**
+ * Async validator function signature
+ */
+export type AsyncValidator = (
+ value: TValue,
+ ctx: { signal: AbortSignal },
+) => Promise;
+
+/**
+ * Per-field configuration for DynamicForm
+ *
+ * @example
+ * ```typescript
+ * const field: FieldConfig = {
+ * key: 'email',
+ * label: 'Email Address',
+ * type: FieldType.EMAIL,
+ * required: true,
+ * placeholder: 'you@example.com',
+ * };
+ * ```
+ */
+export interface FieldConfig {
+ // ββ Identity ββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Unique field key (must match a key in the Zod schema) */
+ readonly key: string;
+ /** Field label displayed to user */
+ readonly label: string;
+ /** Field type determines which component renders */
+ readonly type: FieldType;
+
+ // ββ Content βββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Placeholder text */
+ readonly placeholder?: string;
+ /** Helper text shown below the field */
+ readonly description?: string;
+ /** Options for select, multi-select, or radio fields */
+ readonly options?: FieldOption[];
+
+ // ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /** Visual required indicator (asterisk) β Zod schema is the real validation gate */
+ readonly required?: boolean;
+ /** Whether field is disabled (can be static or computed from form values) */
+ readonly disabled?: boolean | ((values: Record) => boolean);
+ /** Whether field is read-only */
+ readonly readOnly?: boolean;
+
+ // ββ Async validation ββββββββββββββββββββββββββββββββββββββββββ
+ /** Async validator function (e.g., check email availability) */
+ readonly asyncValidate?: AsyncValidator;
+ /** Debounce delay for async validation in ms (default: 300) */
+ readonly asyncDebounceMs?: number;
+
+ // ββ Conditional visibility ββββββββββββββββββββββββββββββββββββ
+ /** Rule that must be true for field to show */
+ readonly showWhen?: ConditionalRule;
+ /** Rule that when true hides the field */
+ readonly hideWhen?: ConditionalRule;
+
+ // ββ Array field (only when type === FieldType.ARRAY) ββββββββββ
+ /** Field configs for each item in the array */
+ readonly arrayFields?: FieldConfig[];
+ /** Label for add button (default: 'Add') */
+ readonly addLabel?: string;
+ /** Label for remove button (default: 'Remove') */
+ readonly removeLabel?: string;
+ /** Minimum number of rows */
+ readonly minRows?: number;
+ /** Maximum number of rows */
+ readonly maxRows?: number;
+ /** Make rows collapsible (default: false) */
+ readonly collapsible?: boolean;
+ /** Require confirmation before removing a row (default: false) */
+ readonly confirmRemove?: boolean;
+ /** Custom message when array is empty */
+ readonly emptyMessage?: string;
+
+ // ββ File field (only when type === FieldType.FILE) ββββββββββββ
+ /** Accepted file types (MIME types or extensions, e.g., 'image/*', '.pdf', 'application/json') */
+ readonly accept?: string;
+ /** Maximum file size in bytes */
+ readonly maxFileSize?: number;
+ /** Allow multiple file selection */
+ readonly multiple?: boolean;
+
+ // ββ Slider field (only when type === FieldType.SLIDER) ββββββββ
+ /** Minimum value (default: 0) */
+ readonly min?: number;
+ /** Maximum value (default: 100) */
+ readonly max?: number;
+ /** Step increment (default: 1) */
+ readonly step?: number;
+ /** Show current value badge (default: true) */
+ readonly showValue?: boolean;
+
+ // ββ OTP field (only when type === FieldType.OTP) ββββββββββββββ
+ /** Number of OTP digits (default: 6) */
+ readonly otpLength?: 4 | 5 | 6 | 7 | 8;
+
+ // ββ Tags field (only when type === FieldType.TAGS) ββββββββββββ
+ /** Maximum number of tags allowed */
+ readonly maxTags?: number;
+ /** Minimum number of tags required */
+ readonly minTags?: number;
+ /** Allow duplicate tags (default: false) */
+ readonly allowDuplicates?: boolean;
+
+ // ββ Rating field (only when type === FieldType.RATING) ββββββββ
+ /** Maximum rating value (default: 5) */
+ readonly maxRating?: number;
+ /** Allow half-star ratings (default: false) */
+ readonly allowHalf?: boolean;
+
+ // ββ Time field (only when type === FieldType.TIME) ββββββββββββ
+ /** Time step in seconds (default: 60) */
+ readonly timeStep?: number;
+
+ // ββ Layout ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /**
+ * Column span in grid layout.
+ * Can be a number (1-12) or responsive object for different breakpoints.
+ *
+ * @example Simple: `colSpan: 6` (half width in 12-column grid)
+ * @example Responsive: `colSpan: { default: 12, md: 6, lg: 4 }`
+ */
+ readonly colSpan?: ColSpanValue;
+ /** Custom CSS class */
+ readonly className?: string;
+}
diff --git a/src/models/FormState.ts b/src/models/FormState.ts
new file mode 100644
index 0000000..cc3c2d5
--- /dev/null
+++ b/src/models/FormState.ts
@@ -0,0 +1,66 @@
+/**
+ * FormState - Runtime form state shape
+ * Type Layer β contracts only, zero runtime logic
+ */
+
+import type { FieldValue, FormValues } from '../core/types';
+
+/**
+ * State for a single field
+ */
+export interface FieldState {
+ /** Current field value */
+ readonly value: FieldValue;
+ /** Error message (null if valid) */
+ readonly error: string | null;
+ /** Whether field has been touched (focused then blurred) */
+ readonly touched: boolean;
+ /** Whether field value differs from initial value */
+ readonly dirty: boolean;
+ /** Whether async validation is in progress */
+ readonly isValidating: boolean;
+}
+
+/**
+ * Complete form state managed by useFormKit
+ */
+export interface FormState {
+ /** Current form values */
+ readonly values: TValues;
+ /** Field-level errors (field key β error message) */
+ readonly errors: Partial>;
+ /** Which fields have been touched */
+ readonly touched: Partial>;
+ /** Whether form is currently submitting */
+ readonly isSubmitting: boolean;
+ /** Whether form is valid (no errors) */
+ readonly isValid: boolean;
+ /** Whether any field value has changed from initial */
+ readonly isDirty: boolean;
+ /** Number of times form has been submitted */
+ readonly submitCount: number;
+}
+
+/**
+ * Form context value exposed to child components
+ */
+export interface FormContextValue {
+ /** Get current value for a field */
+ getValue: (key: keyof TValues) => FieldValue;
+ /** Set value for a field */
+ setValue: (key: keyof TValues, value: FieldValue) => void;
+ /** Get error for a field */
+ getError: (key: keyof TValues) => string | null;
+ /** Set error for a field */
+ setError: (key: keyof TValues, error: string | null) => void;
+ /** Get touched state for a field */
+ getTouched: (key: keyof TValues) => boolean;
+ /** Set touched state for a field */
+ setTouched: (key: keyof TValues, touched: boolean) => void;
+ /** Get all form values (may be partial until submission) */
+ getValues: () => Partial;
+ /** Whether form is submitting */
+ isSubmitting: boolean;
+ /** Whether form is valid */
+ isValid: boolean;
+}
diff --git a/src/models/SectionConfig.ts b/src/models/SectionConfig.ts
new file mode 100644
index 0000000..74b54e2
--- /dev/null
+++ b/src/models/SectionConfig.ts
@@ -0,0 +1,144 @@
+/**
+ * SectionConfig - Section/group configuration for form layouts
+ * Type Layer β contracts only, zero runtime logic
+ */
+
+import type { FieldConfig } from './FieldConfig';
+
+/**
+ * Supported grid units for columns and col spans
+ */
+export type GridUnit = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
+
+/**
+ * Shared responsive breakpoint map for grid values
+ */
+export interface ResponsiveGrid {
+ /** Base value (xs/mobile, always applied) */
+ readonly default?: T;
+ /** Small screens (β₯640px) */
+ readonly sm?: T;
+ /** Medium screens (β₯768px) */
+ readonly md?: T;
+ /** Large screens (β₯1024px) */
+ readonly lg?: T;
+ /** Extra large screens (β₯1280px) */
+ readonly xl?: T;
+}
+
+/**
+ * Responsive column configuration
+ * Allows specifying different column counts at different breakpoints
+ *
+ * @example
+ * ```typescript
+ * const columns: ResponsiveColumns = {
+ * default: 1, // Mobile: single column
+ * sm: 2, // Small screens: 2 columns
+ * lg: 3, // Large screens: 3 columns
+ * };
+ * ```
+ */
+export type ResponsiveColumns = ResponsiveGrid;
+
+/**
+ * Responsive column span configuration for individual fields
+ *
+ * @example
+ * ```typescript
+ * const colSpan: ResponsiveColSpan = {
+ * default: 12, // Full width on mobile
+ * md: 6, // Half width on medium+
+ * };
+ * ```
+ */
+export type ResponsiveColSpan = ResponsiveGrid