diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
index 243bdd33d..e5506e3d3 100644
--- a/components/ui/tooltip.tsx
+++ b/components/ui/tooltip.tsx
@@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
- 'z-50 overflow-hidden rounded-md border bg-popover p-4 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
+ 'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border p-4 text-sm shadow-md',
className,
)}
{...props}
diff --git a/dnd-migration-plan.md b/dnd-migration-plan.md
new file mode 100644
index 000000000..b1321610d
--- /dev/null
+++ b/dnd-migration-plan.md
@@ -0,0 +1,247 @@
+# DND System Migration Plan
+
+## Overview
+
+This document outlines the migration strategy from the old DND system (`lib/interviewer/behaviours/DragAndDrop`) to the new system (`lib/dnd/`). The old system uses HOCs, findDOMNode, and Redux state, while the new system uses hooks, refs, and Zustand for state management.
+
+## New DND System Summary
+
+### Key Components
+- **DndStoreProvider**: Context provider that wraps the app
+- **useDragSource**: Hook for creating draggable items
+- **useDropTarget**: Hook for creating drop zones
+- **Built-in accessibility**: Keyboard navigation and screen reader support
+- **Type-safe**: Full TypeScript support with metadata passing
+
+### Key Differences
+- No HOCs - uses hooks instead
+- No findDOMNode - uses refs
+- Built-in keyboard navigation
+- Automatic position tracking
+- Source zone tracking to prevent self-drops
+
+## Components to Migrate
+
+### Phase 1: Simple Components (Low Complexity)
+These components have straightforward DND usage and can be migrated first:
+
+1. **CategoricalItem** (`lib/interviewer/components/CategoricalItem.tsx`)
+ - Uses: DropTarget, MonitorDropTarget HOCs
+ - Migration: Convert to useDropTarget hook
+
+2. **NodeBin** (`lib/interviewer/components/NodeBin.js`)
+ - Uses: DropTarget, MonitorDropTarget HOCs
+ - Migration: Convert to useDropTarget hook with delete functionality
+
+3. **OrdinalBins** (`lib/interviewer/containers/OrdinalBins.js`)
+ - Uses: MonitorDragSource HOC
+ - Migration: Use store state for monitoring drag state
+
+4. **CategoricalList** (`lib/interviewer/containers/CategoricalList/CategoricalList.js`)
+ - Uses: MonitorDragSource HOC
+ - Migration: Use store state for monitoring
+
+5. **NodePanels** (`lib/interviewer/containers/NodePanels.tsx`)
+ - Uses: MonitorDragSource HOC
+ - Migration: Use store state for monitoring
+
+6. **SearchableList** (`lib/interviewer/containers/SearchableList.tsx`)
+ - Uses: useDropMonitor hook
+ - Migration: Adapt to new hook pattern
+
+7. **PresetSwitcher** (`lib/interviewer/containers/Canvas/PresetSwitcher.js`)
+ - Uses: DropObstacle HOC
+ - Migration: May not need migration if just preventing drops
+
+### Phase 2: Medium Complexity Components
+These components have more complex DND patterns:
+
+8. **MultiNodeBucket** (`lib/interviewer/components/MultiNodeBucket.tsx`)
+ - Uses: DragSource HOC, NO_SCROLL constant
+ - Migration: Convert to useDragSource hook
+
+9. **NodeList** (`lib/interviewer/components/NodeList.js`)
+ - Uses: DragSource, DropTarget, Monitor HOCs
+ - Complex hover state logic
+ - Migration: Convert to both hooks, handle hover states
+
+10. **HyperList** (`lib/interviewer/containers/HyperList/HyperList.js`)
+ - Uses: DragSource, DropTarget, MonitorDropTarget HOCs
+ - Virtual list integration
+ - Migration: Careful integration with virtual scrolling
+
+### Phase 3: Canvas Components - EXCLUDED FROM CURRENT MIGRATION
+These components will remain on the old DND system and require a separate custom solution:
+
+11. **NodeBucket** (`lib/interviewer/containers/Canvas/NodeBucket.js`)
+ - Uses: DragSource, DropObstacle HOCs
+ - **Status**: EXCLUDED - staying on old system
+
+12. **NodeLayout** (`lib/interviewer/containers/Canvas/NodeLayout.js`)
+ - Uses: DropTarget HOC
+ - **Status**: EXCLUDED - staying on old system
+
+13. **LayoutNode** (`lib/interviewer/components/Canvas/LayoutNode.js`)
+ - Uses: DragManager directly
+ - **Status**: EXCLUDED - staying on old system
+
+14. **Annotations** (`lib/interviewer/containers/Canvas/Annotations.js`)
+ - Uses: DragManager, NO_SCROLL
+ - **Status**: EXCLUDED - staying on old system
+
+## Additional Changes Required
+
+### 1. Add DndStoreProvider to InterviewShell
+Location: `app/(interview)/interview/_components/InterviewShell.tsx`
+
+```tsx
+import { DndStoreProvider } from '~/lib/dnd';
+
+// Wrap the Provider components:
+
+
+
+
+
+
+```
+
+### 2. Update Imports
+- Replace imports from `~/lib/interviewer/behaviours/DragAndDrop`
+- Import from `~/lib/dnd` instead
+
+### 3. Convert HOC Patterns to Hooks
+- Replace HOC wrapping with hook usage
+- Move props to hook options
+- Handle ref forwarding properly
+
+### 4. Handle State Management
+- Monitor components can use the DND store state
+- Redux actions remain unchanged for node operations
+
+## Migration Strategy
+
+### Approach
+1. **Incremental Migration**: Migrate one component at a time
+2. **Backward Compatibility**: Keep old system during migration
+3. **Testing**: Test each component after migration
+4. **Start Simple**: Begin with Phase 1 components
+
+### Component Migration Template
+```tsx
+// Old HOC pattern
+export default compose(
+ DragSource({ /* options */ }),
+ DropTarget({ /* options */ })
+)(Component);
+
+// New hook pattern
+const Component = () => {
+ const { dragProps } = useDragSource({
+ type: 'node',
+ metadata: { /* data */ },
+ announcedName: 'Node'
+ });
+
+ const { dropProps } = useDropTarget({
+ id: 'unique-id',
+ accepts: ['node'],
+ onDrop: (metadata) => { /* handler */ }
+ });
+
+ return
Content
;
+};
+```
+
+## Special Considerations
+
+### Accessibility
+- New system has built-in keyboard navigation
+- Ensure announcedName is provided for all draggable/droppable elements
+- Test with screen readers after migration
+
+### TypeScript
+- Convert `.js` files to `.tsx` where beneficial
+- Use proper typing for metadata
+- Replace `interface` with `type` as per guidelines
+
+## Canvas Components: Critical Migration Challenges
+
+### Where Canvas Components Are Used
+
+The Canvas components are core to two major interview interfaces:
+
+1. **Sociogram Interface**: Interactive network visualization for sociometric data collection
+ - Uses `NodeLayout`, `NodeBucket`, and `LayoutNode` for positioned node manipulation
+ - Enables drag-and-drop positioning, edge creation, and automatic layout simulation
+
+2. **Narrative Interface**: Storytelling and network visualization with preset layouts
+ - Uses `NodeLayout` for displaying nodes according to preset configurations
+ - Supports multiple layouts, convex hulls, annotations, and highlighting
+
+### Why Portal Rendering Is a Critical Concern
+
+The Canvas components use a complex **hybrid DOM/React architecture**:
+
+**Current Implementation:**
+- `NodeLayout` creates DOM elements imperatively using `document.createElement('div')`
+- Each node gets absolutely positioned with `position: 'absolute'` and `transform: 'translate(-50%, -50%)'`
+- `LayoutNode` uses `ReactDOM.createPortal` to render React components into these pre-created DOM elements
+- `DragManager` attaches directly to portal DOM elements outside React's event system
+
+**Migration Challenges:**
+- Modern DND libraries expect consistent React component trees with standard event propagation
+- Portal architecture separates visual DOM layout from logical React tree
+- Custom drag previews use imperative DOM manipulation instead of declarative React patterns
+- Event handling mixes imperative listeners with React synthetic events
+
+### Why Coordinate Handling Is a Critical Concern
+
+The Canvas system uses a **dual coordinate system** with complex transformations:
+
+**Current Implementation:**
+- **Relative coordinates**: Nodes stored as percentages (0-1) in Redux for layout variables
+- **Screen coordinates**: Calculated pixel positions for visual rendering via ScreenManager
+- **Real-time conversion**: Every drag operation converts between coordinate systems multiple times
+- **Responsive handling**: Uses ResizeObserver and viewport offset calculations
+
+**Migration Challenges:**
+- Modern DND libraries work in screen coordinates only
+- Custom ScreenManager handles viewport dimensions and dynamic resizing
+- Coordinate clamping and transformation logic is deeply integrated
+- Two-mode networks require different coordinate systems per node type
+
+**Specific Technical Complexity:**
+```javascript
+// Relative to screen conversion
+x: (x - 0.5) * width + 0.5 * width
+y: (y - 0.5) * height + 0.5 * height
+
+// Screen to relative conversion
+x: clamp((x - viewportX) / width, 0, 1)
+y: clamp((y - viewportY) / height, 0, 1)
+```
+
+### Migration Decisions Made
+
+1. **Canvas Components**: Will remain on the old DND system for now and require a separate custom solution later
+2. **State Management**: Migrate fully to Zustand as components are migrated
+3. **Testing**: Use Playwright MCP for verifying migrations
+
+## Next Steps
+
+1. Set up DndStoreProvider in InterviewShell
+2. Start with Phase 1 components (simple drop targets)
+3. Create test protocols using Playwright MCP to verify functionality
+4. Migrate state from Redux to Zustand as each component is converted
+5. Document any issues or edge cases discovered during migration
+
+## Success Criteria
+
+- All non-Canvas components migrated (Phases 1-2)
+- Canvas components (Phase 3) remain on old system until custom solution is developed
+- Sociogram interface excluded as specified
+- No regressions in functionality for migrated components
+- Improved accessibility for migrated components
+- Cleaner, more maintainable code using hooks instead of HOCs
+- State management migrated from Redux to Zustand for DND-related state
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 2c3040ded..abbd478db 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -13,10 +13,10 @@ services:
volumes:
- postgres:/var/lib/postgresql/data
healthcheck:
- test: ["CMD", "pg_isready", '-U', 'postgres']
+ test: ['CMD', 'pg_isready', '-U', 'postgres']
interval: 5s
timeout: 10s
retries: 5
volumes:
postgres:
- name: fresco-dev-db-volume
\ No newline at end of file
+ name: fresco-dev-db-volume
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 018285011..f72df2d3c 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -1,6 +1,11 @@
services:
fresco:
- image: "ghcr.io/complexdatacollective/fresco:latest"
+ environment:
+ - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres
+ - DATABASE_URL_UNPOOLED=postgresql://postgres:postgres@postgres:5432/postgres
+ - PUBLIC_URL=https://mywebsite.com
+ - UPLOADTHING_TOKEN='token'
+ image: 'ghcr.io/complexdatacollective/fresco:latest'
depends_on:
postgres:
condition: service_healthy
@@ -8,7 +13,7 @@ services:
- .:/app/next-app
restart: always
ports:
- - 0:3000
+ - 3000:3000
networks:
- fresco_network
environment:
@@ -21,13 +26,13 @@ services:
image: postgres:16-alpine
restart: always
environment:
- POSTGRES_DB: fresco
- env_file:
- - .env
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=postgres
+ - POSTGRES_DB=postgres
volumes:
- - postgres:/var/lib/postgresql/fresco_data
+ - postgres:/var/lib/postgresql/data:Z
healthcheck:
- test: ["CMD", "pg_isready", '-U', '${POSTGRES_USER}']
+ test: ['CMD', 'pg_isready', '-U', 'postgres']
interval: 5s
timeout: 10s
retries: 5
diff --git a/env.js b/env.js
index 632917733..273f4bb6e 100644
--- a/env.js
+++ b/env.js
@@ -37,6 +37,7 @@ export const env = createEnv({
.enum(['development', 'test', 'production'])
.default('development'),
SANDBOX_MODE: strictBooleanSchema,
+ PREVIEW_MODE: strictBooleanSchema,
APP_VERSION: z.string().optional(),
COMMIT_HASH: z.string().optional(),
},
@@ -52,6 +53,7 @@ export const env = createEnv({
DISABLE_ANALYTICS: process.env.DISABLE_ANALYTICS,
INSTALLATION_ID: process.env.INSTALLATION_ID,
SANDBOX_MODE: process.env.SANDBOX_MODE,
+ PREVIEW_MODE: process.env.PREVIEW_MODE,
APP_VERSION: process.env.APP_VERSION,
COMMIT_HASH: process.env.COMMIT_HASH,
USE_NEON_POSTGRES_ADAPTER: process.env.USE_NEON_POSTGRES_ADAPTER,
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 000000000..0613d530a
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,196 @@
+import eslint from '@eslint/js';
+import tseslint from 'typescript-eslint';
+import nextPlugin from '@next/eslint-plugin-next';
+import eslintConfigPrettier from 'eslint-config-prettier/flat';
+import storybookPlugin from 'eslint-plugin-storybook';
+import importPlugin from 'eslint-plugin-import';
+import reactPlugin from 'eslint-plugin-react';
+import reactHooksPlugin from 'eslint-plugin-react-hooks';
+import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
+import { fixupPluginRules } from '@eslint/compat';
+import globals from 'globals';
+
+export default tseslint.config(
+ // Global ignores
+ {
+ ignores: [
+ 'node_modules/**',
+ '**/*.test.*',
+ 'public/**',
+ 'storybook-static/**',
+ '.next/**',
+ ],
+ },
+
+ // Base recommended for all files
+ eslint.configs.recommended,
+
+ // TypeScript configs - applies to all files but type-checked rules only work on TS
+ ...tseslint.configs.recommendedTypeChecked,
+ ...tseslint.configs.stylisticTypeChecked,
+
+ // Parser options with project service
+ {
+ languageOptions: {
+ parserOptions: {
+ projectService: {
+ allowDefaultProject: ['.storybook/*.ts', '.storybook/*.tsx'],
+ },
+ tsconfigRootDir: import.meta.dirname,
+ },
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ },
+ },
+
+ // Disable type-checked rules for JS files
+ {
+ files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'],
+ ...tseslint.configs.disableTypeChecked,
+ },
+
+ // Next.js plugin
+ nextPlugin.configs['core-web-vitals'],
+ {
+ rules: {
+ '@next/next/no-img-element': 'off',
+ },
+ },
+
+ // React plugins
+ {
+ plugins: {
+ 'react': reactPlugin,
+ 'react-hooks': fixupPluginRules(reactHooksPlugin),
+ },
+ settings: {
+ react: {
+ version: 'detect',
+ },
+ },
+ rules: {
+ ...reactPlugin.configs.recommended.rules,
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+ 'react/react-in-jsx-scope': 'off',
+ 'react/prop-types': 'off',
+ 'react/no-unknown-property': 'off',
+ 'react/jsx-no-target-blank': 'off',
+ },
+ },
+
+ // jsx-a11y plugin (accessibility rules from eslint-config-next)
+ {
+ plugins: {
+ 'jsx-a11y': jsxA11yPlugin,
+ },
+ rules: {
+ 'jsx-a11y/alt-text': [
+ 'warn',
+ {
+ elements: ['img'],
+ img: ['Image'],
+ },
+ ],
+ 'jsx-a11y/aria-props': 'warn',
+ 'jsx-a11y/aria-proptypes': 'warn',
+ 'jsx-a11y/aria-unsupported-elements': 'warn',
+ 'jsx-a11y/role-has-required-aria-props': 'warn',
+ 'jsx-a11y/role-supports-aria-props': 'warn',
+ },
+ },
+
+ // Import plugin - base config for all files (just no-cycle)
+ {
+ plugins: {
+ import: fixupPluginRules(importPlugin),
+ },
+ settings: {
+ 'import/resolver': {
+ typescript: {
+ project: './tsconfig.json',
+ alwaysTryTypes: true,
+ },
+ node: {
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
+ },
+ },
+ },
+ rules: {
+ 'import/no-cycle': 'error',
+ 'import/no-anonymous-default-export': 'off',
+ },
+ },
+
+ // Import plugin - recommended rules for JS files only (matching old config)
+ {
+ files: ['**/*.js', '**/*.jsx'],
+ settings: {
+ 'import/resolver': {
+ typescript: {
+ project: './tsconfig.json',
+ alwaysTryTypes: true,
+ },
+ node: {
+ extensions: ['.js', '.jsx'],
+ },
+ },
+ },
+ rules: {
+ 'import/no-unresolved': 'error',
+ 'import/named': 'error',
+ 'import/namespace': 'error',
+ 'import/default': 'error',
+ 'import/export': 'error',
+ 'import/no-named-as-default': 'warn',
+ 'import/no-named-as-default-member': 'warn',
+ 'import/no-duplicates': 'warn',
+ },
+ },
+
+ // Storybook
+ ...storybookPlugin.configs['flat/recommended'],
+
+ // Custom rules for TypeScript files only (type-checked rules)
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ rules: {
+ '@typescript-eslint/switch-exhaustiveness-check': 'error',
+ '@typescript-eslint/no-misused-promises': [
+ 'error',
+ {
+ checksVoidReturn: false,
+ },
+ ],
+ },
+ },
+
+ // Custom rules for all files (non-type-checked rules)
+ {
+ rules: {
+ '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
+ 'no-process-env': 'error',
+ 'no-console': 'error',
+ '@typescript-eslint/consistent-type-imports': [
+ 'warn',
+ {
+ prefer: 'type-imports',
+ fixStyle: 'inline-type-imports',
+ },
+ ],
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ {
+ caughtErrors: 'none',
+ argsIgnorePattern: '^_',
+ },
+ ],
+ 'no-unreachable': 'error',
+ },
+ },
+
+ // Prettier must be last
+ eslintConfigPrettier,
+);
diff --git a/fresco.config.ts b/fresco.config.ts
index b4d779800..0cc6747ba 100644
--- a/fresco.config.ts
+++ b/fresco.config.ts
@@ -1,5 +1,7 @@
export const PROTOCOL_EXTENSION = '.netcanvas';
-export const APP_SUPPORTED_SCHEMA_VERSIONS = [7];
+export const APP_SUPPORTED_SCHEMA_VERSIONS = [7, 8];
+export const MIN_ARCHITECT_VERSION_FOR_PREVIEW = '7.0.1';
+
// If unconfigured, the app will shut down after 2 hours (7200000 ms)
export const UNCONFIGURED_TIMEOUT = 7200000;
diff --git a/global.d.ts b/global.d.ts
new file mode 100644
index 000000000..883196dac
--- /dev/null
+++ b/global.d.ts
@@ -0,0 +1,32 @@
+/**
+ * Global type definitions for e2e test environment
+ */
+/* eslint-disable no-var, @typescript-eslint/no-explicit-any */
+
+import { type Protocol } from '@codaco/protocol-validation';
+import type { User } from '~/lib/db/generated/client';
+import type { TestEnvironment } from './tests/e2e/fixtures/test-environment';
+
+declare global {
+ namespace globalThis {
+ var __TEST_ENVIRONMENT__: TestEnvironment | undefined;
+ var __INTERVIEWS_TEST_DATA__:
+ | {
+ admin: {
+ user: User;
+ username: string;
+ password: string;
+ };
+ protocol: Protocol;
+ participants: any[];
+ }
+ | undefined;
+ var __INTERVIEWS_CONTEXT__:
+ | {
+ restoreSnapshot: (name: string) => Promise
;
+ }
+ | undefined;
+ }
+}
+
+export {};
diff --git a/hooks/use-data-table.tsx b/hooks/use-data-table.tsx
index 8063b80bd..8e727efeb 100644
--- a/hooks/use-data-table.tsx
+++ b/hooks/use-data-table.tsx
@@ -21,7 +21,7 @@ import type {
DataTableSearchableColumn,
FilterParam,
SortableField,
-} from '~/lib/data-table/types';
+} from '~/components/DataTable/types';
import { debounce } from 'es-toolkit';
import { useTableStateFromSearchParams } from '~/app/dashboard/_components/ActivityFeed/useTableStateFromSearchParams';
diff --git a/hooks/useCanvas.ts b/hooks/useCanvas.ts
index c217019bb..89b445dc2 100644
--- a/hooks/useCanvas.ts
+++ b/hooks/useCanvas.ts
@@ -1,4 +1,4 @@
-import { useRef, useEffect } from 'react';
+import { useEffect, useRef } from 'react';
const resizeCanvas = (
context: CanvasRenderingContext2D,
diff --git a/hooks/usePortal.ts b/hooks/usePortal.ts
index c800a94fc..4e6acea0d 100644
--- a/hooks/usePortal.ts
+++ b/hooks/usePortal.ts
@@ -1,20 +1,26 @@
-import { useEffect, useMemo, type ReactNode } from 'react';
+'use client';
+
+import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
-const usePortal = (targetElement: HTMLElement = document.body) => {
+const usePortal = (customTarget?: HTMLElement) => {
+ const [mounted, setMounted] = useState(false);
+ const [targetElement, setTargetElement] = useState(null);
+
useEffect(() => {
- // Ensure the hook is only used in the browser
- const isBrowser = typeof window !== 'undefined';
- if (!isBrowser) {
- return;
- }
- }, []);
+ setMounted(true);
+ setTargetElement(customTarget ?? document.body);
+
+ return () => setMounted(false);
+ }, [customTarget]);
const Portal = useMemo(() => {
return ({ children }: { children: ReactNode }) => {
- return createPortal(children, targetElement);
+ return mounted && targetElement
+ ? createPortal(children, targetElement)
+ : null;
};
- }, [targetElement]);
+ }, [mounted, targetElement]);
return Portal;
};
diff --git a/hooks/useProtocolImport.tsx b/hooks/useProtocolImport.tsx
index 4431cf689..0f24720af 100644
--- a/hooks/useProtocolImport.tsx
+++ b/hooks/useProtocolImport.tsx
@@ -1,3 +1,7 @@
+import {
+ CURRENT_SCHEMA_VERSION,
+ getMigrationInfo,
+} from '@codaco/protocol-validation';
import { queue } from 'async';
import { XCircle } from 'lucide-react';
import { hash } from 'ohash';
@@ -11,20 +15,156 @@ import {
} from '~/components/ProtocolImport/JobReducer';
import { AlertDialogDescription } from '~/components/ui/AlertDialog';
import { APP_SUPPORTED_SCHEMA_VERSIONS } from '~/fresco.config';
-import { uploadFiles } from '~/lib/uploadthing-client-helpers';
-import { getExistingAssetIds, getProtocolByHash } from '~/queries/protocols';
+import {
+ validateAndMigrateProtocol,
+ type ProtocolValidationError,
+} from '~/lib/protocol/validateAndMigrateProtocol';
+import { uploadFiles } from '~/lib/uploadthing/client-helpers';
+import { getNewAssetIds, getProtocolByHash } from '~/queries/protocols';
import { type AssetInsertType } from '~/schemas/protocol';
import { DatabaseError } from '~/utils/databaseError';
import { ensureError } from '~/utils/ensureError';
-import { formatNumberList } from '~/utils/general';
import {
fileAsArrayBuffer,
getProtocolAssets,
getProtocolJson,
} from '~/utils/protocolImport';
-// Utility helper for adding artificial delay to async functions
-// const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+/**
+ * Formats a list of numbers into a human-readable string.
+ */
+function formatNumberList(numbers: number[]): string {
+ // "1"
+ if (numbers.length === 1) {
+ return numbers[0]!.toString();
+ }
+
+ // "1 and 2"
+ if (numbers.length === 2) {
+ return numbers.join(' and ');
+ }
+
+ // "1, 2, and 3"
+ const lastNumber = numbers.pop();
+ const formattedList = numbers.join(', ') + `, and ${lastNumber}`;
+
+ return formattedList;
+}
+
+/**
+ * Creates error payload for protocol validation failures.
+ */
+function createValidationErrorPayload(
+ fileName: string,
+ validationError: ProtocolValidationError,
+) {
+ switch (validationError.error) {
+ case 'invalid-object':
+ return {
+ id: fileName,
+ rawError: new Error('Invalid protocol object'),
+ error: {
+ title: 'Invalid protocol file',
+ description: (
+
+ The uploaded file does not contain a valid protocol.
+
+ ),
+ },
+ };
+
+ case 'unsupported-version':
+ return {
+ id: fileName,
+ rawError: new Error('Protocol version not supported'),
+ error: {
+ title: 'Protocol version not supported',
+ description: (
+
+ The protocol you uploaded is not compatible with this version of
+ the app. Fresco supports protocols using version number
+ {APP_SUPPORTED_SCHEMA_VERSIONS.length > 1 ? 's' : ''}{' '}
+ {formatNumberList([...APP_SUPPORTED_SCHEMA_VERSIONS])}.
+
+ ),
+ },
+ };
+
+ case 'validation-failed': {
+ const resultAsString = JSON.stringify(
+ validationError.validationResult,
+ null,
+ 2,
+ );
+ const validationResult = validationError.validationResult as {
+ error: { issues: { message: string; path: string[] }[] };
+ };
+
+ return {
+ id: fileName,
+ rawError: new Error('Protocol validation failed', {
+ cause: validationError.validationResult,
+ }),
+ error: {
+ title: 'The protocol is invalid!',
+ description: (
+ <>
+
+ The protocol you uploaded is invalid. See the details below for
+ specific validation errors that were found.
+
+
+ If you believe that your protocol should be valid please ask for
+ help via our{' '}
+
+ community forum
+
+ .
+
+ >
+ ),
+ additionalContent: (
+
+
+ {validationResult.error.issues.map(({ message, path }, i) => (
+ -
+
+
+ {message}{' '}
+
+ ({path.join(' > ')})
+
+
+
+ ))}
+
+
+ ),
+ },
+ };
+ }
+
+ case 'missing-dependencies': {
+ const missingDeps = validationError.missingDependencies;
+ return {
+ id: fileName,
+ rawError: new Error('Migration dependencies missing'),
+ error: {
+ title: 'Migration failed',
+ description: (
+
+ The protocol requires migration but is missing required
+ information: {missingDeps.join(', ')}.
+
+ ),
+ },
+ };
+ }
+ }
+}
export const useProtocolImport = () => {
const [jobs, dispatch] = useReducer(jobReducer, jobInitialState);
@@ -62,97 +202,40 @@ export const useProtocolImport = () => {
},
});
- // Check if the protocol version is compatible with the app.
- const protocolVersion = protocolJson.schemaVersion;
- if (!APP_SUPPORTED_SCHEMA_VERSIONS.includes(protocolVersion)) {
- dispatch({
- type: 'UPDATE_ERROR',
- payload: {
- id: fileName,
- rawError: new Error('Protocol version not supported'),
- error: {
- title: 'Protocol version not supported',
- description: (
-
- The protocol you uploaded is not compatible with this version
- of the app. Fresco supports protocols using version number
- {APP_SUPPORTED_SCHEMA_VERSIONS.length > 1 ? 's' : ''}{' '}
- {formatNumberList(APP_SUPPORTED_SCHEMA_VERSIONS)}.
-
- ),
- },
- },
- });
-
- return;
+ // Build migration dependencies based on what's required
+ const dependencies: Record = {};
+ if (protocolJson.schemaVersion < CURRENT_SCHEMA_VERSION) {
+ const migrationInfo = getMigrationInfo(
+ protocolJson.schemaVersion,
+ CURRENT_SCHEMA_VERSION,
+ );
+ for (const dep of migrationInfo.dependencies) {
+ if (dep === 'name') {
+ // Derive protocol name from filename (remove .netcanvas extension)
+ dependencies.name = fileName.replace(/\.netcanvas$/i, '');
+ }
+ }
}
- const { validateProtocol } = await import('@codaco/protocol-validation');
-
- const validationResult = await validateProtocol(protocolJson);
-
- if (!validationResult.isValid) {
- const resultAsString = JSON.stringify(validationResult, null, 2);
+ const validationResult = await validateAndMigrateProtocol(
+ protocolJson,
+ dependencies,
+ );
+ if (!validationResult.success) {
dispatch({
type: 'UPDATE_ERROR',
- payload: {
- id: fileName,
- rawError: new Error('Protocol validation failed', {
- cause: validationResult,
- }),
- error: {
- title: 'The protocol is invalid!',
- description: (
- <>
-
- The protocol you uploaded is invalid. See the details below
- for specific validation errors that were found.
-
-
- If you believe that your protocol should be valid please ask
- for help via our{' '}
-
- community forum
-
- .
-
- >
- ),
- additionalContent: (
-
-
- {[
- ...validationResult.schemaErrors,
- ...validationResult.logicErrors,
- ].map((validationError, i) => (
- -
-
-
- {validationError.message}{' '}
-
- ({validationError.path})
-
-
-
- ))}
-
-
- ),
- },
- },
+ payload: createValidationErrorPayload(fileName, validationResult),
});
return;
}
// After this point, assume the protocol is valid.
+ const validatedProtocol = validationResult.protocol;
// Check if the protocol already exists in the database
- const protocolHash = hash(protocolJson);
+ const protocolHash = hash(validatedProtocol);
const exists = await getProtocolByHash(protocolHash);
if (exists) {
dispatch({
@@ -176,30 +259,44 @@ export const useProtocolImport = () => {
return;
}
- const assets = await getProtocolAssets(protocolJson, zip);
-
- const newAssets: typeof assets = [];
+ const { fileAssets, apikeyAssets } = await getProtocolAssets(
+ validatedProtocol,
+ zip,
+ );
+ const newAssets: typeof fileAssets = [];
const existingAssetIds: string[] = [];
-
- let newAssetsWithCombinedMetadata: AssetInsertType = [];
+ let newAssetsWithCombinedMetadata: AssetInsertType[] = [];
+ const newApikeyAssets: typeof apikeyAssets = [];
// Check if the assets are already in the database.
// If yes, add them to existingAssetIds to be connected to the protocol.
- // If not, add them to newAssets to be uploaded.
-
+ // If not, add files to newAssets to be uploaded
+ // and add apikeys to newApikeyAssets to be created in the database with the protocol
try {
- const newAssetIds = await getExistingAssetIds(
- assets.map((asset) => asset.assetId),
+ const newFileAssetIds = await getNewAssetIds(
+ fileAssets.map((asset) => asset.assetId),
);
- assets.forEach((asset) => {
- if (newAssetIds.includes(asset.assetId)) {
+ fileAssets.forEach((asset) => {
+ if (newFileAssetIds.includes(asset.assetId)) {
newAssets.push(asset);
} else {
existingAssetIds.push(asset.assetId);
}
});
+
+ const newApikeyAssetIds = await getNewAssetIds(
+ apikeyAssets.map((apiKey) => apiKey.assetId),
+ );
+
+ apikeyAssets.forEach((apiKey) => {
+ if (newApikeyAssetIds.includes(apiKey.assetId)) {
+ newApikeyAssets.push(apiKey);
+ } else {
+ existingAssetIds.push(apiKey.assetId);
+ }
+ });
} catch (e) {
throw new Error('Error checking for existing assets');
}
@@ -300,9 +397,9 @@ export const useProtocolImport = () => {
});
const result = await insertProtocol({
- protocol: protocolJson,
+ protocol: validatedProtocol,
protocolName: fileName,
- newAssets: newAssetsWithCombinedMetadata,
+ newAssets: [...newAssetsWithCombinedMetadata, ...newApikeyAssets],
existingAssetIds: existingAssetIds,
});
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 000000000..dd3782309
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "target": "ES2022",
+ "jsx": "react",
+ "allowImportingTsExtensions": true,
+ "strictNullChecks": true,
+ "strictFunctionTypes": true
+ },
+ "exclude": ["node_modules", "**/node_modules/*"]
+}
diff --git a/knip.json b/knip.json
index 1561660e7..b7e4f2663 100644
--- a/knip.json
+++ b/knip.json
@@ -1,13 +1,22 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"ignore": [
- "lib/ui/components/Sprites/ExportSprite.js",
"utils/auth.ts",
"load-test.js",
- "lib/db/generated/**",
- "lib/db/index.ts",
- "scripts/*.ts"
+ "styles/tailwind-motion-spring.ts",
+ "tests/e2e/scripts/analyze-test-isolation.ts"
],
- "ignoreDependencies": ["server-only", "prop-types", "sharp", "pg"],
- "ignoreBinaries": ["docker-compose", "playwright"]
+ "ignoreExportsUsedInFile": {
+ "type": true
+ },
+ "ignoreDependencies": ["sharp", "esbuild"],
+ "ignoreBinaries": ["docker-compose"],
+ "playwright": {
+ "config": "tests/e2e/playwright.config.ts",
+ "entry": [
+ "tests/e2e/global-setup.ts",
+ "tests/e2e/global-teardown.ts",
+ "tests/e2e/suites/**/*.spec.ts"
+ ]
+ }
}
diff --git a/lib/__tests__/preview-protocol-pruning.test.ts b/lib/__tests__/preview-protocol-pruning.test.ts
new file mode 100644
index 000000000..f0e335ff4
--- /dev/null
+++ b/lib/__tests__/preview-protocol-pruning.test.ts
@@ -0,0 +1,142 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Mock Prisma
+const mockPrisma = {
+ protocol: {
+ findMany: vi.fn(),
+ deleteMany: vi.fn(),
+ },
+ asset: {
+ findMany: vi.fn(),
+ deleteMany: vi.fn(),
+ },
+};
+
+// Mock uploadthing API
+const mockDeleteFiles = vi.fn();
+const mockGetUTApi = vi.fn();
+
+// Mock the db module
+vi.mock('~/lib/db', () => ({
+ prisma: mockPrisma,
+}));
+
+// Mock uploadthing server-helpers
+vi.mock('~/lib/uploadthing/server-helpers', () => ({
+ getUTApi: () =>
+ mockGetUTApi() as Promise<{ deleteFiles: typeof mockDeleteFiles }>,
+}));
+
+describe('prunePreviewProtocols', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetUTApi.mockResolvedValue({
+ deleteFiles: mockDeleteFiles,
+ });
+ });
+
+ it('should delete protocols older than 24 hours', async () => {
+ // Dynamic import to ensure mocks are set up
+ const { prunePreviewProtocols } =
+ await import('../preview-protocol-pruning');
+
+ const oldProtocol = {
+ id: 'old-protocol',
+ hash: 'hash-123',
+ name: 'Old Protocol',
+ };
+
+ mockPrisma.protocol.findMany.mockResolvedValue([oldProtocol]);
+ mockPrisma.asset.findMany.mockResolvedValue([]);
+ mockPrisma.protocol.deleteMany.mockResolvedValue({ count: 1 });
+
+ const result = await prunePreviewProtocols();
+
+ expect(result.deletedCount).toBe(1);
+ expect(result.error).toBeUndefined();
+ expect(mockPrisma.protocol.deleteMany).toHaveBeenCalledWith({
+ where: {
+ id: {
+ in: ['old-protocol'],
+ },
+ },
+ });
+ });
+
+ it('should not delete protocols newer than 24 hours', async () => {
+ const { prunePreviewProtocols } =
+ await import('../preview-protocol-pruning');
+
+ mockPrisma.protocol.findMany.mockResolvedValue([]);
+
+ const result = await prunePreviewProtocols();
+
+ expect(result.deletedCount).toBe(0);
+ expect(mockPrisma.protocol.deleteMany).not.toHaveBeenCalled();
+ });
+
+ it('should delete associated assets from UploadThing', async () => {
+ const { prunePreviewProtocols } =
+ await import('../preview-protocol-pruning');
+
+ const oldProtocol = {
+ id: 'old-protocol',
+ hash: 'hash-123',
+ name: 'Old Protocol',
+ };
+
+ const assets = [{ key: 'ut-key-1' }, { key: 'ut-key-2' }];
+
+ mockDeleteFiles.mockResolvedValue({ success: true });
+ mockPrisma.protocol.findMany.mockResolvedValue([oldProtocol]);
+ mockPrisma.asset.findMany.mockResolvedValue(assets);
+ mockPrisma.asset.deleteMany.mockResolvedValue({ count: 2 });
+ mockPrisma.protocol.deleteMany.mockResolvedValue({ count: 1 });
+
+ const result = await prunePreviewProtocols();
+
+ expect(result.deletedCount).toBe(1);
+ expect(mockDeleteFiles).toHaveBeenCalledWith(['ut-key-1', 'ut-key-2']);
+ });
+
+ it('should handle errors gracefully', async () => {
+ const { prunePreviewProtocols } =
+ await import('../preview-protocol-pruning');
+
+ mockPrisma.protocol.findMany.mockRejectedValue(new Error('Database error'));
+
+ const result = await prunePreviewProtocols();
+
+ expect(result.deletedCount).toBe(0);
+ expect(result.error).toBe('Database error');
+ });
+
+ it('should only query for preview protocols with pending/completed cutoffs', async () => {
+ const { prunePreviewProtocols } =
+ await import('../preview-protocol-pruning');
+
+ mockPrisma.protocol.findMany.mockResolvedValue([]);
+
+ await prunePreviewProtocols();
+
+ // Verify that findMany was called with the correct query structure
+ expect(mockPrisma.protocol.findMany).toHaveBeenCalled();
+
+ const mockCalls = mockPrisma.protocol.findMany.mock.calls;
+ expect(mockCalls.length).toBeGreaterThan(0);
+
+ // Check that the query includes isPreview: true
+ const firstCall = mockCalls[0];
+ expect(firstCall).toBeDefined();
+ if (firstCall) {
+ type QueryArgs = {
+ where?: {
+ isPreview?: boolean;
+ OR?: { isPending?: boolean; importedAt?: { lt?: Date } }[];
+ };
+ };
+ const args = firstCall[0] as QueryArgs;
+ expect(args.where?.isPreview).toBe(true);
+ }
+ });
+});
diff --git a/lib/cache.ts b/lib/cache.ts
index bf27da5aa..50d647be7 100644
--- a/lib/cache.ts
+++ b/lib/cache.ts
@@ -1,25 +1,31 @@
import { revalidateTag, unstable_cache } from 'next/cache';
-type StaticTag =
- | 'activityFeed'
- | 'appSettings'
- | 'getInterviews'
- | 'summaryStatistics'
- | 'getParticipants'
- | 'getInterviewById'
- | 'getProtocols'
- | 'getInterviewsForExport'
- | 'getProtocolsByHash'
- | 'getExistingAssetIds'
- | 'interviewCount'
- | 'protocolCount'
- | 'participantCount';
+export const CacheTags = [
+ 'activityFeed',
+ 'appSettings',
+ 'getInterviews',
+ 'summaryStatistics',
+ 'getParticipants',
+ 'getProtocols',
+ 'getProtocolsByHash',
+ 'getExistingAssetIds',
+ 'interviewCount',
+ 'protocolCount',
+ 'participantCount',
+ 'getApiTokens',
+] as const satisfies string[];
+
+type StaticTag = (typeof CacheTags)[number];
type DynamicTag = `${StaticTag}-${string}`;
type CacheTag = StaticTag | DynamicTag;
-export function safeRevalidateTag(tag: CacheTag) {
+export function safeRevalidateTag(tag: CacheTag | CacheTag[]) {
+ if (Array.isArray(tag)) {
+ tag.forEach((t) => revalidateTag(t));
+ return;
+ }
revalidateTag(tag);
}
diff --git a/lib/db/index.ts b/lib/db/index.ts
index 175fb7ef6..c5213a2ee 100644
--- a/lib/db/index.ts
+++ b/lib/db/index.ts
@@ -1,7 +1,14 @@
-import { PrismaPg } from '@prisma/adapter-pg';
+import {
+ CurrentProtocolSchema,
+ type VersionedProtocol,
+ VersionedProtocolSchema,
+} from '@codaco/protocol-validation';
+import { NcNetworkSchema } from '@codaco/shared-consts';
import { PrismaNeon } from '@prisma/adapter-neon';
+import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '~/lib/db/generated/client';
import { env } from '~/env';
+import { StageMetadataSchema } from '~/lib/interviewer/ducks/modules/session';
const createPrismaClient = () => {
const adapter = env.USE_NEON_POSTGRES_ADAPTER
@@ -11,6 +18,112 @@ const createPrismaClient = () => {
return new PrismaClient({
adapter,
log: env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
+ }).$extends({
+ query: {
+ appSettings: {
+ async findUnique({ args, query }) {
+ // Only intercept queries with a key
+ if (!args.where?.key) {
+ return query(args);
+ }
+
+ const key = args.where.key;
+ const result = await query(args);
+
+ // Return the raw value or null if no result
+ // The query layer will handle parsing to proper types
+ return {
+ key,
+ value: result?.value ?? null,
+ };
+ },
+ },
+ },
+ result: {
+ interview: {
+ network: {
+ needs: {
+ network: true,
+ },
+ compute: ({ network }) => {
+ return NcNetworkSchema.parse(network);
+ },
+ },
+ stageMetadata: {
+ needs: {
+ stageMetadata: true,
+ },
+ compute: ({ stageMetadata }) => {
+ if (!stageMetadata) {
+ return null;
+ }
+ return StageMetadataSchema.parse(stageMetadata);
+ },
+ },
+ },
+ protocol: {
+ stages: {
+ needs: {
+ name: true,
+ schemaVersion: true,
+ stages: true,
+ codebook: true,
+ },
+ compute: ({ name, schemaVersion, stages, codebook }) => {
+ const protocolSchema = VersionedProtocolSchema.parse({
+ name,
+ schemaVersion,
+ stages,
+ codebook,
+ experiments: {},
+ });
+ return protocolSchema.stages;
+ },
+ },
+ codebook: {
+ needs: {
+ name: true,
+ schemaVersion: true,
+ codebook: true,
+ },
+ compute: ({
+ name,
+ schemaVersion,
+ codebook,
+ }): VersionedProtocol['codebook'] => {
+ const protocolSchema = VersionedProtocolSchema.parse({
+ name,
+ schemaVersion,
+ stages: [],
+ codebook,
+ experiments: {},
+ });
+ return protocolSchema.codebook;
+ },
+ },
+ experiments: {
+ needs: {
+ name: true,
+ schemaVersion: true,
+ experiments: true,
+ },
+ compute: ({ name, schemaVersion, experiments }) => {
+ if (schemaVersion < 8 || !experiments) {
+ return {};
+ }
+
+ const protocolSchema = CurrentProtocolSchema.parse({
+ name,
+ schemaVersion,
+ stages: [],
+ codebook: {},
+ experiments,
+ });
+ return protocolSchema.experiments ?? {};
+ },
+ },
+ },
+ },
});
};
diff --git a/lib/db/migrations/20250122184616_add_asset_value/migration.sql b/lib/db/migrations/20250122184616_add_asset_value/migration.sql
new file mode 100644
index 000000000..e9d48ffe8
--- /dev/null
+++ b/lib/db/migrations/20250122184616_add_asset_value/migration.sql
@@ -0,0 +1,8 @@
+-- AlterTable
+ALTER TABLE "Asset" ADD COLUMN "value" TEXT;
+
+-- AlterTable
+ALTER TABLE "_AssetToProtocol" ADD CONSTRAINT "_AssetToProtocol_AB_pkey" PRIMARY KEY ("A", "B");
+
+-- DropIndex
+DROP INDEX "_AssetToProtocol_AB_unique";
diff --git a/lib/db/migrations/20250426201139_add_protocol_experiments/migration.sql b/lib/db/migrations/20250426201139_add_protocol_experiments/migration.sql
new file mode 100644
index 000000000..c64395f6c
--- /dev/null
+++ b/lib/db/migrations/20250426201139_add_protocol_experiments/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Protocol" ADD COLUMN "experiments" JSONB;
diff --git a/lib/db/migrations/20251118073416_add_preview_mode_and_api_tokens/migration.sql b/lib/db/migrations/20251118073416_add_preview_mode_and_api_tokens/migration.sql
new file mode 100644
index 000000000..d8ab77f63
--- /dev/null
+++ b/lib/db/migrations/20251118073416_add_preview_mode_and_api_tokens/migration.sql
@@ -0,0 +1,23 @@
+-- AlterEnum
+ALTER TYPE "AppSetting" ADD VALUE 'previewModeRequireAuth';
+
+-- AlterTable
+ALTER TABLE "Protocol" ADD COLUMN "isPreview" BOOLEAN NOT NULL DEFAULT false;
+
+-- CreateTable
+CREATE TABLE "ApiToken" (
+ "id" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "description" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUsedAt" TIMESTAMP(3),
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+
+ CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ApiToken_token_key" ON "ApiToken"("token");
+
+-- CreateIndex
+CREATE INDEX "Protocol_isPreview_importedAt_idx" ON "Protocol"("isPreview", "importedAt");
diff --git a/lib/db/migrations/20251204222357_add_protocol_is_pending/migration.sql b/lib/db/migrations/20251204222357_add_protocol_is_pending/migration.sql
new file mode 100644
index 000000000..96f4db178
--- /dev/null
+++ b/lib/db/migrations/20251204222357_add_protocol_is_pending/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Protocol" ADD COLUMN "isPending" BOOLEAN NOT NULL DEFAULT false;
diff --git a/lib/db/schema.prisma b/lib/db/schema.prisma
index 6cab87ca8..a42d70ad8 100644
--- a/lib/db/schema.prisma
+++ b/lib/db/schema.prisma
@@ -49,6 +49,11 @@ model Protocol {
codebook Json
assets Asset[]
interviews Interview[]
+ experiments Json?
+ isPreview Boolean @default(false)
+ isPending Boolean @default(false)
+
+ @@index([isPreview, importedAt])
}
model Asset {
@@ -59,6 +64,7 @@ model Asset {
url String
size Int
protocols Protocol[]
+ value String?
@@index(fields: [assetId, key])
}
@@ -98,6 +104,7 @@ enum AppSetting {
disableAnalytics
disableSmallScreenOverlay
uploadThingToken
+ previewModeRequireAuth
}
model AppSettings {
@@ -111,3 +118,12 @@ model Events {
type String
message String
}
+
+model ApiToken {
+ id String @id @default(cuid())
+ token String @unique
+ description String?
+ createdAt DateTime @default(now())
+ lastUsedAt DateTime?
+ isActive Boolean @default(true)
+}
diff --git a/lib/dialogs/ControlledDialog.stories.tsx b/lib/dialogs/ControlledDialog.stories.tsx
new file mode 100644
index 000000000..576445ac9
--- /dev/null
+++ b/lib/dialogs/ControlledDialog.stories.tsx
@@ -0,0 +1,45 @@
+import { type Meta, type StoryObj } from '@storybook/nextjs-vite';
+import Paragraph from '~/components/typography/Paragraph';
+import { ControlledDialog } from './ControlledDialog';
+
+const meta: Meta = {
+ title: 'Systems/Dialogs/ControlledDialog',
+ component: ControlledDialog,
+ argTypes: {
+ open: {
+ control: 'boolean',
+ description: 'Controls whether the dialog is open or closed',
+ },
+ title: {
+ control: 'text',
+ description: 'Dialog title',
+ },
+ description: {
+ control: 'text',
+ description: 'Dialog description',
+ },
+ },
+ parameters: {
+ layout: 'centered',
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ open: false,
+ title: 'Dialog Title',
+ description: 'This is the description',
+ },
+ render: (args) => (
+
+
+ Use the story controls to control the open state of the dialog.
+
+
+
+ ),
+};
diff --git a/lib/dialogs/ControlledDialog.tsx b/lib/dialogs/ControlledDialog.tsx
new file mode 100644
index 000000000..cea45107b
--- /dev/null
+++ b/lib/dialogs/ControlledDialog.tsx
@@ -0,0 +1,19 @@
+import { useEffect, useRef } from 'react';
+import { Dialog, type DialogProps } from './Dialog';
+
+/**
+ * A variation of Dialog that has an internal useEffect to handle triggering
+ * the native dialog's showModal and close methods.
+ */
+export const ControlledDialog = ({ open, ...rest }: DialogProps) => {
+ const ref = useRef(null);
+ useEffect(() => {
+ if (open) {
+ ref.current?.showModal();
+ } else {
+ ref.current?.close();
+ }
+ }, [open]);
+
+ return ;
+};
diff --git a/lib/dialogs/Dialog.stories.tsx b/lib/dialogs/Dialog.stories.tsx
new file mode 100644
index 000000000..bb22767f7
--- /dev/null
+++ b/lib/dialogs/Dialog.stories.tsx
@@ -0,0 +1,87 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { fn } from 'storybook/test';
+import { Button } from '../ui/components';
+import { Dialog, type DialogProps } from './Dialog';
+
+const meta: Meta = {
+ title: 'Systems/Dialogs/Dialog',
+ component: Dialog,
+ args: {
+ closeDialog: fn(),
+ },
+ argTypes: {
+ accent: {
+ control: {
+ type: 'select',
+ options: ['default', 'danger', 'success', 'warning', 'info'],
+ },
+ },
+ title: {
+ control: 'text',
+ },
+ description: {
+ control: 'text',
+ },
+ },
+ parameters: {
+ layout: 'fullscreen',
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+const DialogTemplate = (args: DialogProps) => (
+
+);
+
+export const Default: Story = {
+ args: {
+ title: 'Default Dialog',
+ description: 'This is a default dialog description',
+ },
+ render: (args) => ,
+};
+
+export const Success: Story = {
+ args: {
+ title: 'Success Dialog',
+ description: 'This dialog indicates success.',
+ accent: 'success',
+ },
+ render: (args) => ,
+};
+
+export const Danger: Story = {
+ args: {
+ title: 'Danger Dialog',
+ description: 'This dialog indicates danger.',
+ accent: 'danger',
+ },
+ render: (args) => ,
+};
+
+export const Warning: Story = {
+ args: {
+ title: 'Warning Dialog',
+ description: 'This dialog indicates a warning.',
+ accent: 'warning',
+ },
+ render: (args) => ,
+};
+
+export const Info: Story = {
+ args: {
+ title: 'Info Dialog',
+ description: 'This dialog provides some information.',
+ accent: 'info',
+ },
+ render: (args) => ,
+};
diff --git a/lib/dialogs/Dialog.tsx b/lib/dialogs/Dialog.tsx
new file mode 100644
index 000000000..5e014e0e4
--- /dev/null
+++ b/lib/dialogs/Dialog.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import React, { forwardRef, useId } from 'react';
+import CloseButton from '~/components/CloseButton';
+import Surface from '~/components/layout/Surface';
+import Heading from '~/components/typography/Heading';
+import Paragraph from '~/components/typography/Paragraph';
+import { cn } from '~/utils/shadcn';
+
+export type DialogProps = {
+ title: string;
+ description?: string;
+ accent?: 'default' | 'danger' | 'success' | 'warning' | 'info';
+ closeDialog: () => void;
+} & React.DialogHTMLAttributes;
+
+/**
+ * Native HTML Dialog modified so that it can be used with React.
+ *
+ * For use with `useDialog` and `DialogProvider`. Use `ControlledDialog` in
+ * situations where you need to control the dialog's open state manually.
+ *
+ * Implementation Notes:
+ *
+ * - The reason this component has an inner Surface component is that the native
+ * dialog uses margin for centering, so we cannot customise margin to ensure
+ * a visible space from screen edge on small screens.
+ * - `allow-discrete` is implemented in the tailwind config, and is required for
+ * the dialog to be able to be animated correctly. See: https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior#allow-discrete
+ * - There's no way I can think of to use framer-motion for animation here, as
+ * the animation state is linked to the `open` attribute of the dialog, which
+ * can't be read from the dialog itself (although _can_ be read by mutation
+ * observer... but that's a bit much)
+ */
+export const Dialog = forwardRef(({
+ title,
+ description,
+ children,
+ closeDialog,
+ accent,
+ ...rest
+}, ref) => {
+ const id = useId();
+ // TODO: automatic focus on least destructive action, or initialFocusEl ref.
+ return (
+
+ );
+});
+
+Dialog.displayName = 'Dialog';
diff --git a/lib/dialogs/DialogProvider.tsx b/lib/dialogs/DialogProvider.tsx
new file mode 100644
index 000000000..58dafe613
--- /dev/null
+++ b/lib/dialogs/DialogProvider.tsx
@@ -0,0 +1,164 @@
+'use client';
+
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+ type RefObject,
+} from 'react';
+import { flushSync } from 'react-dom';
+import { generatePublicId } from '~/utils/generatePublicId';
+import { Button } from '../ui/components';
+import { Dialog } from './Dialog';
+
+type ConfirmDialog = {
+ type: 'confirm';
+ hideCancel?: boolean;
+ confirmText?: string;
+ cancelText?: string;
+ children?: React.ReactNode;
+};
+
+type CustomDialog = {
+ type: 'custom';
+ renderContent: (resolve: (value: T | null) => void) => React.ReactNode;
+};
+
+type Dialog = {
+ id?: string;
+ title: string;
+ description: string;
+ accent?: 'default' | 'danger' | 'success' | 'warning' | 'info';
+} & (ConfirmDialog | CustomDialog);
+
+type DialogState = Dialog & {
+ id: string;
+ resolveCallback: (value: unknown) => void;
+ ref: RefObject;
+};
+
+type DialogContextType = {
+ closeDialog: (id: string, value: T | null) => Promise;
+ openDialog: (dialogProps: Dialog) => Promise;
+};
+
+const DialogContext = createContext(null);
+
+const DialogProvider: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ const [dialogs, setDialogs] = useState([]);
+
+ const openDialog = useCallback(
+ async (dialogProps: Dialog): Promise => {
+ const dialogRef = React.createRef();
+
+ return new Promise((resolveCallback) => {
+ flushSync(() =>
+ setDialogs((prevDialogs) => [
+ ...prevDialogs,
+ {
+ ...dialogProps,
+ id: dialogProps.id ?? generatePublicId(),
+ resolveCallback,
+ ref: dialogRef,
+ } as DialogState,
+ ]),
+ );
+
+ if (dialogRef.current) {
+ dialogRef.current.showModal();
+ }
+ });
+ },
+ [setDialogs],
+ );
+
+ const closeDialog = useCallback(
+ async (id: string, value: T | null) => {
+ const dialog = dialogs.find((dialog) => dialog.id === id);
+
+ if (!dialog) {
+ throw new Error(`Dialog with ID ${id} does not exist`);
+ }
+
+ if (dialog.ref.current) {
+ dialog.ref.current.close();
+ dialog.resolveCallback(value);
+ }
+
+ // Wait for the animation to finish before removing from state
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ setDialogs((prevDialogs) =>
+ prevDialogs.filter((dialog) => dialog.id !== id),
+ );
+ },
+ [dialogs, setDialogs],
+ );
+
+ const contextValue: DialogContextType = {
+ closeDialog,
+ openDialog,
+ };
+
+ return (
+
+ {children}
+ {dialogs.map((dialog) => {
+ if (dialog.type === 'confirm') {
+ return (
+
+ );
+ }
+
+ if (dialog.type === 'custom') {
+ return (
+
+ );
+ }
+ })}
+
+ );
+};
+
+export const useDialog = (): DialogContextType => {
+ const context = useContext(DialogContext);
+ if (!context) {
+ throw new Error('useDialog must be used within a DialogProvider');
+ }
+
+ return context;
+};
+
+export default DialogProvider;
diff --git a/lib/dialogs/useDialog.stories.tsx b/lib/dialogs/useDialog.stories.tsx
new file mode 100644
index 000000000..b8e7f67d9
--- /dev/null
+++ b/lib/dialogs/useDialog.stories.tsx
@@ -0,0 +1,96 @@
+/* eslint-disable no-console */
+import type { StoryObj } from '@storybook/nextjs-vite';
+import { fn } from 'storybook/test';
+import { Button } from '../ui/components';
+import { useDialog } from './DialogProvider';
+
+const meta = {
+ title: 'Systems/Dialogs/useDialog',
+ args: {
+ closeDialog: fn(),
+ },
+ argTypes: {
+ accent: {
+ control: {
+ type: 'select',
+ options: ['default', 'danger', 'success', 'warning', 'info'],
+ },
+ },
+ title: {
+ control: 'text',
+ },
+ description: {
+ control: 'text',
+ },
+ },
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'fullscreen',
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
+export const Default: Story = {
+ render: () => {
+ const { openDialog } = useDialog();
+
+ const confirmDialog = async () => {
+ // Return type should be boolean | null
+ const result = await openDialog({
+ type: 'confirm',
+ title: 'Confirm dialog title',
+ description:
+ 'confirm dialog description, which is read by screen readers',
+ cancelText: 'Custom cancel text',
+ confirmText: 'Custom confirm text',
+ });
+
+ console.log('got result', result);
+ };
+
+ const customDialog = async () => {
+ // Return type should be inferred as string | null
+ const result = await openDialog({
+ type: 'custom',
+ title: 'Custom Dialog',
+ description: 'This is a custom dialog',
+ // 'resolve' should be inferred as (value: string | null) => void
+ renderContent: (resolve) => {
+ const handleConfirm = async () => {
+ const confirmed = await openDialog({
+ type: 'confirm',
+ title: 'Are you really sure?',
+ accent: 'danger',
+ description: 'This action cannot be undone.',
+ });
+
+ if (confirmed) {
+ resolve('confirmed');
+ }
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+ },
+ });
+
+ console.log('got result', result);
+ };
+
+ return (
+
+
+
+
+ );
+ },
+};
diff --git a/lib/dnd/DndStoreProvider.tsx b/lib/dnd/DndStoreProvider.tsx
new file mode 100644
index 000000000..52d2068ac
--- /dev/null
+++ b/lib/dnd/DndStoreProvider.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import { type ReactNode, createContext, useContext, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import { useStore } from 'zustand';
+
+import { type DndStore, createDndStore } from './store';
+
+type DndStoreApi = ReturnType;
+
+const DndStoreContext = createContext(undefined);
+
+type DndStoreProviderProps = {
+ children: ReactNode;
+};
+
+export const DndStoreProvider = ({ children }: DndStoreProviderProps) => {
+ const storeRef = useRef(null);
+ storeRef.current ??= createDndStore();
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+export const useDndStore = (selector: (store: DndStore) => T): T => {
+ const dndStoreContext = useContext(DndStoreContext);
+
+ if (!dndStoreContext) {
+ throw new Error(`useDndStore must be used within DndStoreProvider`);
+ }
+
+ return useStore(dndStoreContext, selector);
+};
+
+export const useDndStoreApi = () => {
+ const dndStoreContext = useContext(DndStoreContext);
+
+ if (!dndStoreContext) {
+ throw new Error(`useDndStoreApi must be used within DndStoreProvider`);
+ }
+
+ return dndStoreContext;
+};
+
+function DragPreview() {
+ const dragPreview = useDndStore((state) => state.dragPreview);
+ const dragPosition = useDndStore((state) => state.dragPosition);
+ const dragItem = useDndStore((state) => state.dragItem);
+ const isDragging = !!dragItem;
+
+ if (!isDragging || !dragPreview || typeof document === 'undefined') {
+ return null;
+ }
+
+ const previewStyles: React.CSSProperties = {
+ transform: `translate(${dragPosition?.x ?? 0}px, ${dragPosition?.y ?? 0}px) translate(-50%, -50%)`,
+ };
+
+ return createPortal(
+
+ {dragPreview}
+
,
+ document.body,
+ );
+}
diff --git a/lib/dnd/__tests__/hooks.test.ts b/lib/dnd/__tests__/hooks.test.ts
new file mode 100644
index 000000000..7645c430d
--- /dev/null
+++ b/lib/dnd/__tests__/hooks.test.ts
@@ -0,0 +1,154 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { createDndStore, defaultInitState } from '../store';
+import type { DropTarget } from '../types';
+
+// Import setup
+import './setup';
+
+describe('Drag and Drop Hooks Integration', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ // Create a fresh store instance for each test
+ store = createDndStore(defaultInitState);
+ vi.clearAllMocks();
+ });
+
+ describe('Store Integration', () => {
+ it('should handle drag lifecycle', () => {
+ const dragItem = {
+ id: 'test-drag',
+ type: 'test',
+ metadata: { type: 'test', id: '1' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 100,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().startDrag(dragItem, position);
+
+ expect(store.getState().isDragging).toBe(true);
+ expect(store.getState().dragItem).toEqual(dragItem);
+ expect(store.getState().dragPosition).toEqual(position);
+
+ store.getState().endDrag();
+
+ expect(store.getState().isDragging).toBe(false);
+ expect(store.getState().dragItem).toBe(null);
+ expect(store.getState().dragPosition).toBe(null);
+ });
+
+ it('should register and unregister drop targets', () => {
+ const dropTarget: DropTarget = {
+ id: 'test-target',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ accepts: ['test'],
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+
+ expect(store.getState().dropTargets.has('test-target')).toBe(true);
+
+ store.getState().unregisterDropTarget('test-target');
+
+ expect(store.getState().dropTargets.has('test-target')).toBe(false);
+ });
+
+ it('should handle drag type acceptance correctly', () => {
+ const dropTarget: DropTarget = {
+ id: 'fruit-target',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ accepts: ['fruit'],
+ };
+
+ const dragItem = {
+ id: 'apple',
+ type: 'fruit',
+ metadata: { type: 'fruit', id: 'apple-1' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 50,
+ y: 50,
+ width: 30,
+ height: 30,
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().startDrag(dragItem, position);
+ store.getState().updateDragPosition(50, 50);
+
+ // Should set active drop target for accepted type
+ expect(store.getState().activeDropTargetId).toBe('fruit-target');
+ });
+
+ it('should not set active drop target for rejected types', () => {
+ const dropTarget: DropTarget = {
+ id: 'fruit-target',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ accepts: ['fruit'],
+ };
+
+ const dragItem = {
+ id: 'carrot',
+ type: 'vegetable',
+ metadata: { type: 'vegetable', id: 'carrot-1' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 50,
+ y: 50,
+ width: 30,
+ height: 30,
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().startDrag(dragItem, position);
+ store.getState().updateDragPosition(50, 50);
+
+ // Should not set active drop target for rejected type
+ expect(store.getState().activeDropTargetId).toBe(null);
+ });
+
+ it('should update drag position correctly', () => {
+ const dragItem = {
+ id: 'test-item',
+ type: 'test',
+ metadata: { type: 'test', id: '1' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 100,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().startDrag(dragItem, position);
+ store.getState().updateDragPosition(100, 200);
+
+ const currentDragPosition = store.getState().dragPosition;
+ expect(currentDragPosition?.x).toBe(100);
+ expect(currentDragPosition?.y).toBe(200);
+ expect(currentDragPosition?.width).toBe(50);
+ expect(currentDragPosition?.height).toBe(50);
+ });
+ });
+});
diff --git a/lib/dnd/__tests__/setup.ts b/lib/dnd/__tests__/setup.ts
new file mode 100644
index 000000000..4e27efb9d
--- /dev/null
+++ b/lib/dnd/__tests__/setup.ts
@@ -0,0 +1,60 @@
+// Test setup for drag and drop tests
+import { vi } from 'vitest';
+
+// Mock ResizeObserver
+global.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+
+// Mock IntersectionObserver
+global.IntersectionObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+
+// Mock requestAnimationFrame
+global.requestAnimationFrame = vi
+ .fn()
+ .mockImplementation((cb: FrameRequestCallback) => {
+ setTimeout(cb, 16);
+ return 1;
+ });
+
+global.cancelAnimationFrame = vi.fn();
+
+// Mock pointer events for drag and drop
+Object.defineProperty(global, 'PointerEvent', {
+ value: class PointerEvent extends Event {
+ constructor(type: string, init?: PointerEventInit) {
+ super(type, init);
+ this.pointerId = init?.pointerId ?? 1;
+ this.clientX = init?.clientX ?? 0;
+ this.clientY = init?.clientY ?? 0;
+ }
+ pointerId: number;
+ clientX: number;
+ clientY: number;
+ },
+ writable: true,
+ configurable: true,
+});
+
+// Mock getBoundingClientRect
+Element.prototype.getBoundingClientRect = vi.fn(() => ({
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ top: 0,
+ left: 0,
+ bottom: 100,
+ right: 100,
+ toJSON: vi.fn(),
+}));
+
+// Mock setPointerCapture and releasePointerCapture
+Element.prototype.setPointerCapture = vi.fn();
+Element.prototype.releasePointerCapture = vi.fn();
diff --git a/lib/dnd/__tests__/store.test.ts b/lib/dnd/__tests__/store.test.ts
new file mode 100644
index 000000000..ad2072b08
--- /dev/null
+++ b/lib/dnd/__tests__/store.test.ts
@@ -0,0 +1,326 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { createDndStore, defaultInitState } from '../store';
+import type { DropTarget } from '../types';
+
+describe('DnD Store', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ // Create a fresh store instance for each test
+ store = createDndStore(defaultInitState);
+ });
+
+ describe('startDrag', () => {
+ it('should initialize drag state', () => {
+ const dragItem = {
+ id: 'drag-1',
+ type: 'test',
+ metadata: { type: 'test' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 200,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().startDrag(dragItem, position);
+
+ const state = store.getState();
+ expect(state.isDragging).toBe(true);
+ expect(state.dragItem).toEqual(dragItem);
+ expect(state.dragPosition).toEqual(position);
+ expect(state.activeDropTargetId).toBe(null);
+ });
+
+ it('should update canDrop for compatible drop targets', () => {
+ const dropTarget: DropTarget = {
+ id: 'drop-1',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ accepts: ['test'],
+ };
+
+ const dragItem = {
+ id: 'drag-1',
+ type: 'test',
+ metadata: { type: 'test' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 200,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().startDrag(dragItem, position);
+
+ const state = store.getState();
+ const storedTarget = state.dropTargets.get('drop-1');
+ expect(storedTarget?.canDrop).toBe(true);
+ });
+ });
+
+ describe('updateDragPosition', () => {
+ it('should update drag item position', () => {
+ const dragItem = {
+ id: 'drag-1',
+ type: 'test',
+ metadata: { type: 'test' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 200,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().startDrag(dragItem, position);
+ store.getState().updateDragPosition(150, 250);
+
+ const state = store.getState();
+ expect(state.dragPosition?.x).toBe(150);
+ expect(state.dragPosition?.y).toBe(250);
+ });
+
+ it('should not update if no drag item', () => {
+ store.getState().updateDragPosition(150, 250);
+ const state = store.getState();
+ expect(state.dragItem).toBe(null);
+ });
+ });
+
+ describe('endDrag', () => {
+ it('should clear drag state', () => {
+ const dragItem = {
+ id: 'drag-1',
+ type: 'test',
+ metadata: { type: 'test' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 200,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().startDrag(dragItem, position);
+ store.getState().endDrag();
+
+ const state = store.getState();
+ expect(state.isDragging).toBe(false);
+ expect(state.dragItem).toBe(null);
+ expect(state.dragPosition).toBe(null);
+ expect(state.activeDropTargetId).toBe(null);
+ });
+
+ it('should clear canDrop and isOver states', () => {
+ const dropTarget: DropTarget = {
+ id: 'drop-1',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ accepts: ['test'],
+ };
+
+ const dragItem = {
+ id: 'drag-1',
+ type: 'test',
+ metadata: { type: 'test' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 200,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().startDrag(dragItem, position);
+ store.getState().endDrag();
+
+ const state = store.getState();
+ const storedTarget = state.dropTargets.get('drop-1');
+ expect(storedTarget?.canDrop).toBe(false);
+ expect(storedTarget?.isOver).toBe(false);
+ });
+ });
+
+ describe('registerDropTarget', () => {
+ it('should add drop target to store', () => {
+ const dropTarget: DropTarget = {
+ id: 'drop-1',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ accepts: ['test'],
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+
+ const state = store.getState();
+ const storedTarget = state.dropTargets.get('drop-1');
+ expect(storedTarget).toEqual({
+ ...dropTarget,
+ canDrop: false,
+ isOver: false,
+ });
+ });
+ });
+
+ describe('unregisterDropTarget', () => {
+ it('should remove drop target from store', () => {
+ const dropTarget: DropTarget = {
+ id: 'drop-1',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ accepts: ['test'],
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().unregisterDropTarget('drop-1');
+
+ const state = store.getState();
+ expect(state.dropTargets.has('drop-1')).toBe(false);
+ });
+
+ it('should clear activeDropTargetId if it matches', () => {
+ const dropTarget: DropTarget = {
+ id: 'drop-1',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ accepts: ['test'],
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().setActiveDropTarget('drop-1');
+ store.getState().unregisterDropTarget('drop-1');
+
+ const state = store.getState();
+ expect(state.activeDropTargetId).toBe(null);
+ });
+ });
+
+ describe('hit detection', () => {
+ it('should detect drop target at position and set isOver', () => {
+ const dropTarget: DropTarget = {
+ id: 'drop-1',
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ accepts: ['test'],
+ };
+
+ const dragItem = {
+ id: 'drag-1',
+ type: 'test',
+ metadata: { type: 'test' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 100,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().startDrag(dragItem, position);
+ store.getState().updateDragPosition(100, 100);
+
+ const state = store.getState();
+ expect(state.activeDropTargetId).toBe('drop-1');
+ const storedTarget = state.dropTargets.get('drop-1');
+ expect(storedTarget?.isOver).toBe(true);
+ });
+
+ it('should not set activeDropTargetId if target does not accept', () => {
+ const dropTarget: DropTarget = {
+ id: 'drop-1',
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ accepts: ['other'],
+ };
+
+ const dragItem = {
+ id: 'drag-1',
+ type: 'test',
+ metadata: { type: 'test' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 100,
+ y: 100,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().startDrag(dragItem, position);
+ store.getState().updateDragPosition(100, 100);
+
+ const state = store.getState();
+ expect(state.activeDropTargetId).toBe(null);
+ const storedTarget = state.dropTargets.get('drop-1');
+ expect(storedTarget?.isOver).toBe(false);
+ });
+
+ it('should not set activeDropTargetId if point is outside target', () => {
+ const dropTarget: DropTarget = {
+ id: 'drop-1',
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ accepts: ['test'],
+ };
+
+ const dragItem = {
+ id: 'drag-1',
+ type: 'test',
+ metadata: { type: 'test' },
+ _sourceZone: null,
+ };
+
+ const position = {
+ x: 200,
+ y: 200,
+ width: 50,
+ height: 50,
+ };
+
+ store.getState().registerDropTarget(dropTarget);
+ store.getState().startDrag(dragItem, position);
+ store.getState().updateDragPosition(200, 200);
+
+ const state = store.getState();
+ expect(state.activeDropTargetId).toBe(null);
+ const storedTarget = state.dropTargets.get('drop-1');
+ expect(storedTarget?.isOver).toBe(false);
+ });
+ });
+});
diff --git a/lib/dnd/index.ts b/lib/dnd/index.ts
new file mode 100644
index 000000000..b05fc101f
--- /dev/null
+++ b/lib/dnd/index.ts
@@ -0,0 +1,5 @@
+export { DndStoreProvider, useDndStore } from './DndStoreProvider';
+export type { DndStore } from './store';
+export type { DragMetadata } from './types';
+export { useDragSource } from './useDragSource';
+export { useDropTarget } from './useDropTarget';
diff --git a/lib/dnd/store.ts b/lib/dnd/store.ts
new file mode 100644
index 000000000..c21d731e3
--- /dev/null
+++ b/lib/dnd/store.ts
@@ -0,0 +1,269 @@
+import { subscribeWithSelector } from 'zustand/middleware';
+import { createStore } from 'zustand/vanilla';
+import { type DragItem, type DropTarget } from './types';
+
+// Extended drop target with state
+type DropTargetWithState = DropTarget & {
+ canDrop: boolean;
+ isOver: boolean;
+};
+
+// State types
+type DndState = {
+ dragItem: DragItem | null;
+ dragPosition: { x: number; y: number; width: number; height: number } | null;
+ dragPreview: React.ReactNode | null;
+ dropTargets: Map;
+ activeDropTargetId: string | null;
+ isDragging: boolean;
+};
+
+// Action types
+type DndActions = {
+ startDrag: (
+ item: DragItem,
+ position: { x: number; y: number; width: number; height: number },
+ preview?: React.ReactNode,
+ ) => void;
+ updateDragPosition: (x: number, y: number) => void;
+ endDrag: () => void;
+ registerDropTarget: (target: DropTarget) => void;
+ unregisterDropTarget: (id: string) => void;
+ updateDropTarget: (
+ id: string,
+ bounds: { x: number; y: number; width: number; height: number },
+ ) => void;
+ setActiveDropTarget: (id: string | null) => void;
+ getCompatibleTargets: () => DropTargetWithState[];
+ getDropTargetState: (
+ id: string,
+ ) => { canDrop: boolean; isOver: boolean } | null;
+};
+
+// Combined store type
+export type DndStore = DndState & DndActions;
+
+// Default initial state
+export const defaultInitState: DndState = {
+ dragItem: null,
+ dragPosition: null,
+ dragPreview: null,
+ dropTargets: new Map(),
+ activeDropTargetId: null,
+ isDragging: false,
+};
+
+// Helper function to check if target accepts drag item
+function doesTargetAccept(target: DropTarget, dragItem: DragItem): boolean {
+ const itemType = dragItem.type;
+ const sourceZone = dragItem._sourceZone;
+
+ // Check if the target accepts the item type
+ const acceptsType = target.accepts.includes(itemType);
+
+ // Prevent dropping back into the same zone
+ if (sourceZone && target.id === sourceZone) {
+ return false;
+ }
+
+ return acceptsType;
+}
+
+// Helper to update canDrop for all targets
+function updateCanDropStates(
+ targets: Map,
+ dragItem: DragItem | null,
+): Map {
+ const newTargets = new Map(targets);
+
+ for (const [id, target] of newTargets) {
+ const canDrop = dragItem ? doesTargetAccept(target, dragItem) : false;
+ newTargets.set(id, { ...target, canDrop });
+ }
+
+ return newTargets;
+}
+
+// Factory function to create DnD store
+export const createDndStore = (initState: DndState = defaultInitState) => {
+ return createStore()(
+ subscribeWithSelector((set, get) => ({
+ ...initState,
+
+ startDrag: (item, position, preview) => {
+ const currentTargets = get().dropTargets;
+ const updatedTargets = updateCanDropStates(currentTargets, item);
+
+ set({
+ dragItem: item,
+ dragPosition: position,
+ dragPreview: preview ?? null,
+ isDragging: true,
+ activeDropTargetId: null,
+ dropTargets: updatedTargets,
+ });
+ },
+
+ updateDragPosition: (x, y) => {
+ const state = get();
+ if (!state.dragItem || !state.dragPosition) return;
+
+ // Find drop target at current position
+ let foundTarget: DropTargetWithState | null = null;
+ let newActiveDropTargetId: string | null = null;
+
+ for (const target of state.dropTargets.values()) {
+ if (
+ x >= target.x &&
+ x <= target.x + target.width &&
+ y >= target.y &&
+ y <= target.y + target.height
+ ) {
+ foundTarget = target;
+ break;
+ }
+ }
+
+ if (foundTarget && foundTarget.canDrop) {
+ newActiveDropTargetId = foundTarget.id;
+ }
+
+ // Only update if position or active drop target changed
+ const positionChanged =
+ state.dragPosition &&
+ (state.dragPosition.x !== x || state.dragPosition.y !== y);
+ const activeDropTargetChanged =
+ state.activeDropTargetId !== newActiveDropTargetId;
+
+ if (positionChanged || activeDropTargetChanged) {
+ // Update isOver state for drop targets
+ const newTargets = new Map(state.dropTargets);
+
+ // Clear previous isOver
+ if (
+ state.activeDropTargetId &&
+ state.activeDropTargetId !== newActiveDropTargetId
+ ) {
+ const prevTarget = newTargets.get(state.activeDropTargetId);
+ if (prevTarget) {
+ newTargets.set(state.activeDropTargetId, {
+ ...prevTarget,
+ isOver: false,
+ });
+ }
+ }
+
+ // Set new isOver
+ if (newActiveDropTargetId) {
+ const newTarget = newTargets.get(newActiveDropTargetId);
+ if (newTarget) {
+ newTargets.set(newActiveDropTargetId, {
+ ...newTarget,
+ isOver: true,
+ });
+ }
+ }
+
+ set({
+ dragPosition: state.dragPosition
+ ? { ...state.dragPosition, x, y }
+ : null,
+ activeDropTargetId: newActiveDropTargetId,
+ dropTargets: newTargets,
+ });
+ }
+ },
+
+ endDrag: () => {
+ const currentTargets = get().dropTargets;
+
+ // Clear all canDrop and isOver states
+ const clearedTargets = new Map();
+ for (const [id, target] of currentTargets) {
+ clearedTargets.set(id, { ...target, canDrop: false, isOver: false });
+ }
+
+ // First, set isDragging to false to trigger drop target callbacks
+ set({
+ dragItem: null,
+ dragPosition: null,
+ dragPreview: null,
+ isDragging: false,
+ dropTargets: clearedTargets,
+ // Keep activeDropTargetId for a moment so drop targets can read it
+ });
+
+ // Reset activeDropTargetId after a short delay to allow drop targets to process
+ setTimeout(() => {
+ set({ activeDropTargetId: null });
+ }, 0);
+ },
+
+ registerDropTarget: (target) => {
+ set((state) => {
+ const newTargets = new Map(state.dropTargets);
+
+ // Initialize with current drag state
+ const canDrop = state.dragItem
+ ? doesTargetAccept(target, state.dragItem)
+ : false;
+ const isOver = state.activeDropTargetId === target.id;
+
+ const targetWithState: DropTargetWithState = {
+ ...target,
+ canDrop,
+ isOver,
+ };
+
+ newTargets.set(target.id, targetWithState);
+
+ return { dropTargets: newTargets };
+ });
+ },
+
+ unregisterDropTarget: (id) => {
+ set((state) => {
+ const newTargets = new Map(state.dropTargets);
+ newTargets.delete(id);
+
+ return {
+ dropTargets: newTargets,
+ activeDropTargetId:
+ state.activeDropTargetId === id ? null : state.activeDropTargetId,
+ };
+ });
+ },
+
+ updateDropTarget: (id, bounds) => {
+ set((state) => {
+ const target = state.dropTargets.get(id);
+ if (!target) return state;
+
+ const updatedTarget: DropTargetWithState = { ...target, ...bounds };
+ const newTargets = new Map(state.dropTargets);
+ newTargets.set(id, updatedTarget);
+
+ return { dropTargets: newTargets };
+ });
+ },
+
+ setActiveDropTarget: (id) => {
+ set({ activeDropTargetId: id });
+ },
+
+ // Selectors
+ getCompatibleTargets: () => {
+ const state = get();
+ return Array.from(state.dropTargets.values()).filter(
+ (target) => target.canDrop,
+ );
+ },
+
+ getDropTargetState: (id) => {
+ const target = get().dropTargets.get(id);
+ if (!target) return null;
+ return { canDrop: target.canDrop, isOver: target.isOver };
+ },
+ })),
+ );
+};
diff --git a/lib/dnd/stories/Accessibility.stories.tsx b/lib/dnd/stories/Accessibility.stories.tsx
new file mode 100644
index 000000000..b13097fff
--- /dev/null
+++ b/lib/dnd/stories/Accessibility.stories.tsx
@@ -0,0 +1,1138 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { useCallback, useRef, useState } from 'react';
+import {
+ DndStoreProvider,
+ useDragSource,
+ useDropTarget,
+ type DragMetadata,
+} from '~/lib/dnd';
+import { useAccessibilityAnnouncements } from '~/lib/dnd/useAccessibilityAnnouncements';
+
+// Demo components for accessibility testing
+function AccessibleDragItem({
+ id,
+ type,
+ children,
+ announcedName,
+ style = {},
+}: {
+ id: string;
+ type: string;
+ children: React.ReactNode;
+ announcedName?: string;
+ style?: React.CSSProperties;
+}) {
+ const { dragProps, isDragging } = useDragSource({
+ type,
+ metadata: { type, id },
+ announcedName: announcedName ?? `${type} ${id}`,
+ });
+
+ return (
+ {
+ e.currentTarget.style.borderColor = '#1976d2';
+ e.currentTarget.style.borderWidth = '3px';
+ e.currentTarget.style.boxShadow = '0 0 0 3px rgba(25, 118, 210, 0.3)';
+ e.currentTarget.style.transform = 'translateY(-2px)';
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.borderColor = '#2196f3';
+ e.currentTarget.style.borderWidth = '2px';
+ e.currentTarget.style.boxShadow = 'none';
+ e.currentTarget.style.transform = 'none';
+ }}
+ >
+ {children}
+ {isDragging && (
+
+ Dragging...
+
+ )}
+
+ );
+}
+
+function AccessibleDropZone({
+ id,
+ accepts,
+ announcedName,
+ children,
+ onDrop,
+ onDragEnter,
+ onDragLeave,
+ style = {},
+}: {
+ id: string;
+ accepts: string[];
+ announcedName?: string;
+ children: React.ReactNode;
+ onDrop?: (metadata?: DragMetadata) => void;
+ onDragEnter?: () => void;
+ onDragLeave?: () => void;
+ style?: React.CSSProperties;
+}) {
+ const { dropProps, isOver, willAccept, isDragging } = useDropTarget({
+ id,
+ accepts,
+ announcedName: announcedName ?? `Drop Zone ${id}`,
+ onDrop,
+ onDragEnter,
+ onDragLeave,
+ });
+
+ return (
+ {
+ if (isDragging) {
+ e.currentTarget.style.borderColor = '#ff9800';
+ e.currentTarget.style.borderStyle = 'solid';
+ e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255, 152, 0, 0.3)';
+ e.currentTarget.style.transform = 'scale(1.02)';
+ }
+ }}
+ onBlur={(e) => {
+ if (isDragging) {
+ e.currentTarget.style.borderStyle = 'dashed';
+ e.currentTarget.style.boxShadow = 'none';
+ e.currentTarget.style.transform = 'scale(1)';
+ // Restore original border color based on state
+ const originalColor = willAccept
+ ? isOver
+ ? '#4caf50'
+ : '#2196f3'
+ : '#f44336';
+ e.currentTarget.style.borderColor = originalColor;
+ }
+ }}
+ >
+ {children}
+
+ );
+}
+
+const meta: Meta = {
+ title: 'Systems/DragAndDrop/Accessibility',
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: `
+# Accessibility Features
+
+The drag and drop system includes comprehensive accessibility support:
+
+## ♿ Features Included
+- **Keyboard Navigation**: Full tab navigation and arrow key controls
+- **Screen Reader Support**: ARIA attributes and live announcements
+- **Focus Management**: Clear focus indicators and logical tab order
+- **Semantic HTML**: Proper roles and accessible markup
+
+## ⌨️ Keyboard Controls
+- **Tab**: Navigate between draggable items
+- **Space/Enter**: Start dragging focused item
+- **Arrow Keys**: Navigate between drop zones while dragging
+- **Space/Enter**: Drop item in current zone
+- **Escape**: Cancel drag operation
+
+## 📢 Screen Reader Announcements
+- Drag start/end announcements
+- Drop zone navigation feedback
+- Success/error confirmations
+- Contextual instructions
+
+## 🎯 ARIA Attributes
+- \`aria-grabbed\`: Indicates drag state
+- \`aria-label\`: Accessible names for items
+- \`role="button"\`: Makes items focusable
+ `,
+ },
+ },
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const KeyboardNavigation: Story = {
+ render: () => {
+ const [items, setItems] = useState([
+ { id: '1', name: 'Document A', zone: 'source' },
+ { id: '2', name: 'Document B', zone: 'source' },
+ { id: '3', name: 'Document C', zone: 'source' },
+ ]);
+
+ const [instructions, setInstructions] = useState(
+ 'Tab to focus items, then use Space or Enter to start dragging',
+ );
+
+ const moveItem = (itemId: string, newZone: string) => {
+ setItems((prev) =>
+ prev.map((item) =>
+ item.id === itemId ? { ...item, zone: newZone } : item,
+ ),
+ );
+ setInstructions(`Moved ${itemId} to ${newZone}`);
+ };
+
+ return (
+
+
+
Keyboard Navigation Demo
+
+
+
Instructions
+
+ {instructions}
+
+
+
Keyboard Controls:
+
+ -
+ Tab - Navigate between items
+
+ -
+ Space/Enter - Start/stop dragging
+
+ -
+ ↑↓←→ - Navigate drop zones while dragging
+
+ -
+ Escape - Cancel drag operation
+
+
+
+
+
+
+
+
Source Items
+ {items
+ .filter((item) => item.zone === 'source')
+ .map((item) => (
+
+ 📄 {item.name}
+
+ ))}
+
+
+
+
Target Zones
+
{
+ if (metadata) {
+ const id =
+ typeof metadata.id === 'string' ? metadata.id : '';
+ moveItem(id, 'archive');
+ }
+ }}
+ onDragEnter={() =>
+ setInstructions(
+ 'Over Archive folder - press Space or Enter to drop',
+ )
+ }
+ >
+ 📁 Archive
+
+ {items.filter((item) => item.zone === 'archive').length} items
+
+
+
{
+ if (metadata) {
+ const id =
+ typeof metadata.id === 'string' ? metadata.id : '';
+ moveItem(id, 'trash');
+ }
+ }}
+ onDragEnter={() =>
+ setInstructions('Over Trash - press Space or Enter to delete')
+ }
+ >
+ 🗑️ Trash
+
+ {items.filter((item) => item.zone === 'trash').length} items
+
+
+
+
+
+ );
+ },
+};
+
+// Demo components that log announcements for the screen reader story
+function LoggingDragItem({
+ id,
+ type,
+ children,
+ announcedName,
+ onAnnounce,
+}: {
+ id: string;
+ type: string;
+ children: React.ReactNode;
+ announcedName?: string;
+ onAnnounce: (message: string) => void;
+}) {
+ const { announce } = useAccessibilityAnnouncements();
+
+ const { dragProps, isDragging } = useDragSource({
+ type,
+ metadata: { type, id },
+ announcedName: announcedName ?? `${type} ${id}`,
+ });
+
+ // Custom announcement wrapper
+ const customAnnounce = useCallback(
+ (message: string) => {
+ onAnnounce(message);
+ // Also call the real announce for screen readers
+ announce(message);
+ },
+ [onAnnounce, announce],
+ );
+
+ return (
+ {
+ e.currentTarget.style.borderColor = '#1976d2';
+ e.currentTarget.style.borderWidth = '3px';
+ e.currentTarget.style.boxShadow = '0 0 0 3px rgba(25, 118, 210, 0.3)';
+ e.currentTarget.style.transform = 'translateY(-2px)';
+ }}
+ onBlur={(e) => {
+ e.currentTarget.style.borderColor = '#2196f3';
+ e.currentTarget.style.borderWidth = '2px';
+ e.currentTarget.style.boxShadow = 'none';
+ e.currentTarget.style.transform = 'none';
+ }}
+ onKeyDown={(e) => {
+ // Call the original drag source key handler first
+ if (dragProps.onKeyDown) {
+ dragProps.onKeyDown(e);
+ }
+
+ // Add our custom logging for keyboard start
+ if ((e.key === ' ' || e.key === 'Enter') && !isDragging) {
+ customAnnounce(`Started dragging ${announcedName ?? id}`);
+ }
+ }}
+ >
+ {children}
+ {isDragging && (
+
+ Dragging...
+
+ )}
+
+ );
+}
+
+function LoggingDropZone({
+ id,
+ accepts,
+ announcedName,
+ children,
+ onDrop,
+ onDragEnter,
+ onDragLeave,
+ onAnnounce,
+}: {
+ id: string;
+ accepts: string[];
+ announcedName?: string;
+ children: React.ReactNode;
+ onDrop?: (metadata?: DragMetadata) => void;
+ onDragEnter?: () => void;
+ onDragLeave?: () => void;
+ onAnnounce: (message: string) => void;
+}) {
+ const { dropProps, isOver, willAccept, isDragging } = useDropTarget({
+ id,
+ accepts,
+ announcedName: announcedName ?? `Drop Zone ${id}`,
+ onDrop: (metadata?: DragMetadata) => {
+ if (metadata) {
+ const itemId = typeof metadata.id === 'string' ? metadata.id : 'item';
+ onAnnounce(`Dropped ${itemId} in ${announcedName ?? id}`);
+ onDrop?.(metadata);
+ }
+ },
+ onDragEnter: () => {
+ onAnnounce(`Entered ${announcedName ?? id}`);
+ onDragEnter?.();
+ },
+ onDragLeave: () => {
+ onAnnounce(`Left ${announcedName ?? id}`);
+ onDragLeave?.();
+ },
+ });
+
+ return (
+ {
+ if (isDragging) {
+ e.currentTarget.style.borderColor = '#ff9800';
+ e.currentTarget.style.borderStyle = 'solid';
+ e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255, 152, 0, 0.3)';
+ e.currentTarget.style.transform = 'scale(1.02)';
+ }
+ }}
+ onBlur={(e) => {
+ if (isDragging) {
+ e.currentTarget.style.borderStyle = 'dashed';
+ e.currentTarget.style.boxShadow = 'none';
+ e.currentTarget.style.transform = 'scale(1)';
+ const originalColor = willAccept
+ ? isOver
+ ? '#4caf50'
+ : '#2196f3'
+ : '#f44336';
+ e.currentTarget.style.borderColor = originalColor;
+ }
+ }}
+ >
+ {children}
+
+ );
+}
+
+export const ScreenReaderAnnouncements: Story = {
+ render: () => {
+ const [announcements, setAnnouncements] = useState([]);
+ const [dragCount, setDragCount] = useState(0);
+ const announcementsRef = useRef(null);
+ const { announce } = useAccessibilityAnnouncements();
+
+ const logAnnouncement = useCallback((message: string) => {
+ setAnnouncements((prev) => [
+ ...prev.slice(-4), // Keep last 5 announcements
+ `${new Date().toLocaleTimeString()}: ${message}`,
+ ]);
+
+ // Auto-scroll to bottom
+ setTimeout(() => {
+ if (announcementsRef.current) {
+ announcementsRef.current.scrollTop =
+ announcementsRef.current.scrollHeight;
+ }
+ }, 0);
+ }, []);
+
+ const items = [
+ 'Screen Reader Test Item A',
+ 'Screen Reader Test Item B',
+ 'Screen Reader Test Item C',
+ ];
+
+ return (
+
+
+
Screen Reader Announcements
+
+
+
+
Interactive Elements
+
+ Use keyboard or mouse to interact. All actions will be announced
+ for screen readers and logged to the right.
+
+
+ {items.map((item, index) => (
+
+ {item}
+
+ ))}
+
+
setDragCount((prev) => prev + 1)}
+ onAnnounce={logAnnouncement}
+ >
+ Drop Zone
+
+ Items dropped: {dragCount}
+
+
+
+
+
Live Announcements
+
+ {announcements.length === 0 ? (
+
+ Screen reader announcements will appear here...
+
+ Try dragging items to see the announcements.
+
+ ) : (
+ announcements.map((announcement, index) => (
+
+ {announcement}
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+};
+
+export const AriaAttributes: Story = {
+ render: () => {
+ const [selectedItem, setSelectedItem] = useState(null);
+
+ const items = [
+ {
+ id: 'aria-1',
+ name: 'ARIA Test Item 1',
+ description: 'Has proper aria-label and role',
+ },
+ {
+ id: 'aria-2',
+ name: 'ARIA Test Item 2',
+ description: 'Uses aria-grabbed for drag state',
+ },
+ {
+ id: 'aria-3',
+ name: 'ARIA Test Item 3',
+ description: 'Includes aria-dropeffect information',
+ },
+ ];
+
+ return (
+
+
+
ARIA Attributes Demo
+
+
+
ARIA Attribute Inspection
+
+ Use browser dev tools to inspect the ARIA attributes on draggable
+ items:
+
+
+ -
+
role="button" - Makes items keyboard
+ focusable
+
+ -
+
aria-label - Provides accessible name
+
+ -
+
aria-grabbed - Indicates if item is being dragged
+
+ -
+
aria-dropeffect="move" - Describes the
+ drag operation
+
+ -
+
tabIndex="0" - Enables keyboard
+ navigation
+
+
+
+
+
+
+
Draggable Items with ARIA
+ {items.map((item) => (
+
+
+
+
{item.name}
+
+ {item.description}
+
+
+
+
+
+ ARIA attributes: role, aria-label, aria-grabbed,
+ aria-dropeffect, tabIndex
+
+
+ ))}
+
+
+
+
Drop Zones
+
{
+ if (metadata) {
+ const id =
+ typeof metadata.id === 'string' ? metadata.id : '';
+ setSelectedItem(id);
+ }
+ }}
+ >
+ Primary Zone
+
+ Accepts all items
+
+
+
+ Secondary Zone
+
+ Rejects all test items
+
+
+ {selectedItem && (
+
+ Last dropped: {selectedItem}
+
+ )}
+
+
+
+
+
Accessibility Testing Tips
+
+
+ Screen Reader Testing:
+
+
+ - Test with NVDA, JAWS, or VoiceOver
+ - Verify all elements are announced correctly
+ - Check that drag operations provide clear feedback
+
+
+
+ Keyboard Testing:
+
+
+ - Navigate using Tab key only
+ - Verify all functionality is accessible via keyboard
+ - Test that focus indicators are clearly visible
+
+
+
+ Tools:
+
+
+ - Browser DevTools Accessibility Panel
+ - axe DevTools extension
+ - Lighthouse accessibility audit
+
+
+
+
+
+ );
+ },
+};
+
+export const AccessibilityPlayground: Story = {
+ render: () => {
+ const [config, setConfig] = useState({
+ showFocusRings: true,
+ enableAnnouncements: true,
+ showAriaLabels: false,
+ keyboardOnly: false,
+ });
+
+ const [testResults, setTestResults] = useState([]);
+
+ const runAccessibilityTest = (testName: string) => {
+ const timestamp = new Date().toLocaleTimeString();
+ setTestResults((prev) => [
+ ...prev.slice(-3),
+ `${timestamp}: ${testName} - ✅ Passed`,
+ ]);
+ };
+
+ return (
+
+
+
Accessibility Testing Playground
+
+
+
+
+
+
Test Elements
+
+ Test Element 1
+ {config.showAriaLabels && (
+
+ aria-label: "Accessibility Test Element 1"
+
+ )}
+
+
+
+ Test Element 2
+ {config.showAriaLabels && (
+
+ aria-label: "Accessibility Test Element 2"
+
+ )}
+
+
+
+
+
Test Controls
+
+
+
+
+
+
+
+
+
{
+ if (metadata) {
+ const id =
+ typeof metadata.id === 'string' ? metadata.id : 'unknown';
+ runAccessibilityTest(`Drop of ${id}`);
+ }
+ }}
+ >
+ Test Drop Zone
+
+
+ {testResults.length > 0 && (
+
+
Test Results
+
+ {testResults.map((result, index) => (
+
{result}
+ ))}
+
+
+ )}
+
+
+
+
+ );
+ },
+};
diff --git a/lib/dnd/stories/DragAndDrop.stories.tsx b/lib/dnd/stories/DragAndDrop.stories.tsx
new file mode 100644
index 000000000..fee8e70cb
--- /dev/null
+++ b/lib/dnd/stories/DragAndDrop.stories.tsx
@@ -0,0 +1,339 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { useState } from 'react';
+import {
+ DndStoreProvider,
+ useDragSource,
+ useDropTarget,
+ type DragMetadata,
+} from '~/lib/dnd';
+import { cn } from '~/utils/shadcn';
+
+type Item = {
+ id: string;
+ name: string;
+ type: 'fruit' | 'vegetable' | 'protein';
+};
+
+const initialItems: Item[] = [
+ { id: '1', name: 'Apple', type: 'fruit' },
+ { id: '2', name: 'Banana', type: 'fruit' },
+ { id: '3', name: 'Orange', type: 'fruit' },
+ { id: '4', name: 'Carrot', type: 'vegetable' },
+ { id: '5', name: 'Broccoli', type: 'vegetable' },
+ { id: '6', name: 'Spinach', type: 'vegetable' },
+ { id: '7', name: 'Chicken', type: 'protein' },
+ { id: '8', name: 'Fish', type: 'protein' },
+];
+
+type ItemStore = Record;
+
+function DraggableItem({ item }: { item: Item }) {
+ const { dragProps, isDragging } = useDragSource({
+ type: item.type,
+ metadata: {
+ ...item,
+ },
+ announcedName: item.name, // For screen reader announcements
+ // Custom preview for fruits
+ preview:
+ item.type === 'fruit' ? (
+
+ 🍎 {item.name}
+
+ ) : undefined, // Use default (cloned element) for other types
+ });
+
+ return (
+
+ {item.name}
+
+ );
+}
+
+function DropZone({
+ title,
+ acceptTypes,
+ items,
+ onItemReceived,
+ children,
+}: {
+ title: string;
+ acceptTypes: string[];
+ items: Item[];
+ onItemReceived: (metadata?: DragMetadata) => void;
+ children?: React.ReactNode;
+}) {
+ const { dropProps, willAccept, isOver, isDragging } = useDropTarget({
+ id: `dropzone-${title.toLowerCase().replace(/\s+/g, '-')}`,
+ accepts: acceptTypes,
+ announcedName: title, // For screen reader announcements
+ onDrop: onItemReceived,
+ onDragEnter: () => {
+ // Drag entered
+ },
+ onDragLeave: () => {
+ // Drag left
+ },
+ });
+
+ return (
+
+
{title}
+ {items.length === 0 && !children ? (
+
+ Drop {acceptTypes.join(' or ')} items here
+
+ ) : (
+ <>
+ {children}
+
+ {items.map((item) => (
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
+function ScrollableContainer({ children }: { children: React.ReactNode }) {
+ return (
+
+
Scrollable Container
+
+ This demonstrates dragging from/to scrollable containers
+
+ {children}
+
+
+ Scroll content to test auto-scroll during drag
+
+
+ );
+}
+
+function DragDropExample() {
+ // State to track items in different zones
+ const [itemStore, setItemStore] = useState({
+ source: initialItems,
+ fruits: [],
+ vegetables: [],
+ proteins: [],
+ mixed: [],
+ scrollable: [
+ { id: 's1', name: 'Scrolled Apple', type: 'fruit' },
+ {
+ id: 's2',
+ name: 'Scrolled Tomato',
+ type: 'vegetable',
+ },
+ ],
+ });
+
+ const moveItem = (item: Item, fromZone: string, toZone: string) => {
+ setItemStore((prev) => {
+ const newStore = { ...prev };
+
+ // Remove from source zone
+ const sourceItems = newStore[fromZone] ?? [];
+ newStore[fromZone] = sourceItems.filter((i) => i.id !== item.id);
+
+ // Add to target zone
+ newStore[toZone] ??= [];
+ const targetItems = newStore[toZone];
+ newStore[toZone] = [...targetItems, item];
+
+ return newStore;
+ });
+ };
+
+ const handleItemReceived =
+ (targetZone: string) => (metadata?: DragMetadata) => {
+ if (!metadata) return;
+ const item = findItemById(metadata.id as string);
+
+ // Find source zone by id
+ const sourceZone = Object.keys(itemStore).find((zone) =>
+ itemStore[zone]?.some((i) => i.id === metadata.id),
+ );
+
+ if (item && sourceZone && sourceZone !== targetZone) {
+ moveItem(item, sourceZone, targetZone);
+ }
+ };
+
+ const findItemById = (id: string): Item | null => {
+ for (const items of Object.values(itemStore)) {
+ const found = items.find((item) => item.id === id);
+ if (found) return found;
+ }
+ return null;
+ };
+
+ return (
+
+
+
+
Instructions
+
+ -
+ Mouse/Touch: Drag items between zones
+
+ -
+ Keyboard: Tab to focus items, Space/Enter to
+ start drag, Arrow keys to navigate drop zones, Space/Enter to
+ drop, Escape to cancel
+
+ -
+ Visual Feedback: Success borders = valid drop
+ zones, Destructive borders = invalid zones
+
+ -
+ Type Restrictions: Each zone accepts specific
+ item types
+
+
+
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: 'Systems/DragAndDrop',
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: `
+# Drag and Drop System
+
+A comprehensive drag and drop system with full accessibility support, type safety, and visual feedback.
+
+## Features
+
+- 🎯 **Type-safe drag operations** - Restrict which items can be dropped where
+- ♿ **Full accessibility** - Keyboard navigation and screen reader support
+- 🎨 **Custom drag previews** - Show custom UI while dragging
+- 📱 **Touch support** - Works on mobile and desktop
+- 🔄 **Auto-scroll** - Scroll containers automatically during drag
+- 🎭 **Visual feedback** - Clear indication of valid/invalid drop zones
+
+## Architecture
+
+The system uses React Context (DndStoreProvider) to manage drag state globally, with two main hooks:
+
+- \`useDragSource\` - Makes elements draggable
+- \`useDropTarget\` - Creates drop zones
+
+## Usage
+
+\`\`\`tsx
+// Wrap your app with the provider
+
+
+
+
+// Make an element draggable
+const { dragProps, isDragging } = useDragSource({
+ type: 'item',
+ metadata: { id: '1', name: 'Item 1' },
+ announcedName: 'Item 1',
+});
+
+// Create a drop zone
+const { dropProps, isOver, willAccept } = useDropTarget({
+ id: 'drop-zone-1',
+ accepts: ['item'],
+ onDrop: (metadata) => console.log('Dropped:', metadata),
+});
+\`\`\`
+ `,
+ },
+ },
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const MainExample: Story = {
+ name: 'Complete Example',
+ render: () => ,
+};
diff --git a/lib/dnd/stories/DragSource.stories.tsx b/lib/dnd/stories/DragSource.stories.tsx
new file mode 100644
index 000000000..e13bd06db
--- /dev/null
+++ b/lib/dnd/stories/DragSource.stories.tsx
@@ -0,0 +1,263 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { useState } from 'react';
+import {
+ DndStoreProvider,
+ useDragSource,
+ useDropTarget,
+ type DragMetadata,
+} from '..';
+
+// Simple draggable item component
+function DraggableItem({
+ id,
+ type,
+ children,
+ preview,
+ style = {},
+}: {
+ id: string;
+ type: string;
+ children: React.ReactNode;
+ preview?: React.ReactNode;
+ style?: React.CSSProperties;
+}) {
+ const { dragProps, isDragging } = useDragSource({
+ type,
+ metadata: { type, id },
+ preview,
+ announcedName: `${type} item ${id}`,
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+// Simple drop zone
+function DropZone({
+ accepts,
+ children,
+ onDrop,
+}: {
+ accepts: string[];
+ children: React.ReactNode;
+ onDrop?: (metadata?: DragMetadata) => void;
+}) {
+ const { dropProps, isOver, willAccept } = useDropTarget({
+ id: `drop-zone-${Math.random().toString(36).substr(2, 9)}`,
+ accepts,
+ onDrop,
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+const meta: Meta = {
+ title: 'Systems/DragAndDrop/DragSource',
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component: `
+The \`useDragSource\` hook makes elements draggable. It handles mouse, touch, and keyboard interactions.
+
+## Basic Usage
+\`\`\`tsx
+const { dragProps, isDragging } = useDragSource({
+ type: 'item',
+ metadata: { id: '1', type: 'item' },
+ announcedName: 'Item 1',
+});
+
+return Draggable Item
;
+\`\`\`
+ `,
+ },
+ },
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Basic: Story = {
+ render: () => (
+
+
+
Basic Draggable Items
+
+
+
+ Card Item
+
+
+ Another Card
+
+
+
Drop cards here
+
+
+
+ ),
+};
+
+export const WithPreview: Story = {
+ render: () => (
+
+
+
Custom Preview
+
+
+
+ 🎯 Custom Preview
+
+ }
+ >
+ Item with Custom Preview
+
+
+ Default Preview
+
+
+
Drop items here
+
+
+
+ ),
+};
+
+export const TypeRestrictions: Story = {
+ render: () => {
+ const [lastDrop, setLastDrop] = useState