diff --git a/.cursor/mcp.json b/.cursor/mcp.json index aacf47805..c22c455e0 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -5,6 +5,10 @@ "command": "node", "args": ["/Users/jacobmaynard/Documents/Repos/corates/packages/mcp/server.js"], "env": {} + }, + "ark-ui": { + "command": "npx", + "args": ["-y", "@ark-ui/mcp"] } } } diff --git a/.cursor/rules/corates.mdc b/.cursor/rules/corates.mdc index 492a16dfc..07bdaa2e9 100644 --- a/.cursor/rules/corates.mdc +++ b/.cursor/rules/corates.mdc @@ -15,7 +15,7 @@ The project is split into multiple packages under the `packages/` directory: - `/web`: Frontend application built with SolidJS - `/workers`: Backend services, API endpoints, and database migrations - `/landing`: Marketing and landing site -- `/ui`: Shared UI component library built with Zag.js +- `/ui`: Shared UI component library built with Ark UI - `/shared`: Shared TypeScript utilities and error definitions - `/mcp`: MCP server for development tools and documentation @@ -48,7 +48,7 @@ Do not worry about migrations (client side or backend) unless specifically instr - **Zod**: Schema and input validation (backend) - **Drizzle ORM**: ALL database interactions and migrations - **Better-Auth**: Authentication and user management -- **Zag.js**: UI components from `@corates/ui` package (NOT from local components) +- **Ark UI**: UI components from `@corates/ui` package - **solid-icons**: Icon library (e.g., `solid-icons/bi`, `solid-icons/fi`) ### Code Comments @@ -81,14 +81,14 @@ retries += 1 ### UI Components -**Zag components are in `@corates/ui` package, NOT in local components.** +**Ark UI components are in `@corates/ui` package, NOT in local components.** ```js // CORRECT import { Dialog, Select, Toast } from '@corates/ui'; // WRONG -import { Dialog } from '@/components/zag/Dialog.jsx'; +import { Dialog } from '@/components/ark/Dialog.jsx'; ``` See `ui-components.mdc` for detailed component usage patterns. @@ -112,7 +112,7 @@ See `solidjs.mdc` for detailed reactivity patterns and examples. ## Documentation -- **ALWAYS use Corates MCP tools** for Better-Auth, Drizzle, Icons, linting, and Zag documentation +- **ALWAYS use Corates MCP tools or other MCP** for Better-Auth, Drizzle, Icons, linting, and Ark UI documentation - Reference `style-guide.md` for UI styling guidelines - Reference `docs/error-handling-guide.md` for error handling patterns - See `TESTING.md` for testing guidelines (do NOT add tests unless asked) diff --git a/.cursor/rules/ui-components.mdc b/.cursor/rules/ui-components.mdc index 257e1c152..daa0605af 100644 --- a/.cursor/rules/ui-components.mdc +++ b/.cursor/rules/ui-components.mdc @@ -1,14 +1,14 @@ --- alwaysApply: false -description: "UI component usage patterns including Zag.js components, solid-icons, and import aliases" +description: "UI component usage patterns including Ark UI components, solid-icons, and import aliases" globs: packages/web/**, packages/ui/** --- # UI Components -## Zag.js Components +## Ark UI Components -**Zag components are in `@corates/ui` package, NOT in local components.** +**UI components are in `@corates/ui` package, built with Ark UI (and some remaining Zag.js components). NOT in local components.** ```js // CORRECT - Import from @corates/ui @@ -21,7 +21,7 @@ import { Dialog } from 'packages/web/src/components/zag/Dialog.jsx'; ### Available Components -See `packages/ui/src/zag/index.js` for all available components. Common ones: +See `packages/ui/src/zag/index.js` for all available components. Most components have been migrated to Ark UI. Common ones: - `Dialog`, `ConfirmDialog`, `useConfirmDialog` - `Select`, `Combobox` - `Toast`, `toaster`, `showToast` diff --git a/README.md b/README.md index 34008cd92..633de6d2d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ CoRATES is a web application designed to streamline the entire quality and risk- ## Tech Stack -- **Frontend**: SolidJS, SolidStart, Tailwind CSS, Vite, Zag +- **Frontend**: SolidJS, SolidStart, Tailwind CSS, Vite, Ark UI - **Backend**: Cloudflare Workers, Durable Objects - **Database**: Cloudflare D1 (SQLite) - **Storage**: Cloudflare R2 (PDF documents) diff --git a/docs/architecture/diagrams.md b/docs/architecture/diagrams.md index 5853b7149..a4b373d35 100644 --- a/docs/architecture/diagrams.md +++ b/docs/architecture/diagrams.md @@ -10,11 +10,12 @@ Mermaid diagrams to help new contributors understand the CoRATES application arc ## Diagrams -| # | Diagram | Description | -| --- | ----------------------------------------------------------- | -------------------------------------------- | -| 1 | [Package Architecture](diagrams/01-package-architecture.md) | Monorepo structure and package relationships | -| 2 | [System Architecture](diagrams/02-system-architecture.md) | Frontend, backend, and storage layers | -| 3 | [Sync Flow](diagrams/03-sync-flow.md) | Local-first CRDT sync with Yjs | -| 4 | [Data Model](diagrams/04-data-model.md) | Entity relationships and storage | -| 5 | [Frontend Routes](diagrams/05-frontend-routes.md) | Application routing structure | -| 6 | [API Routes](diagrams/06-api-routes.md) | Backend endpoints and middleware | +| # | Diagram | Description | +| --- | ----------------------------------------------------------- | -------------------------------------------------------------------- | +| 1 | [Package Architecture](diagrams/01-package-architecture.md) | Monorepo structure and package relationships | +| 2 | [System Architecture](diagrams/02-system-architecture.md) | Frontend, backend, and storage layers | +| 3 | [Sync Flow](diagrams/03-sync-flow.md) | Local-first CRDT sync with Yjs | +| 4 | [Data Model](diagrams/04-data-model.md) | Entity relationships and storage | +| 5 | [Frontend Routes](diagrams/05-frontend-routes.md) | Application routing structure | +| 6 | [API Routes](diagrams/06-api-routes.md) | Backend endpoints and middleware | +| 7 | [API Actions](diagrams/07-api-actions.md) | All actions and failure points for projects, studies, and checklists | diff --git a/docs/architecture/diagrams/01-package-architecture.md b/docs/architecture/diagrams/01-package-architecture.md index bd6d06ea8..b4e67f144 100644 --- a/docs/architecture/diagrams/01-package-architecture.md +++ b/docs/architecture/diagrams/01-package-architecture.md @@ -36,6 +36,6 @@ graph TB | `web` | Main SolidJS application | SolidJS, Vite, Tailwind | | `workers` | Backend API and real-time sync | Hono, Cloudflare Workers | | `landing` | Marketing site (includes web app) | SolidStart | -| `ui` | Shared component library | SolidJS, Zag.js | +| `ui` | Shared component library | SolidJS, Ark UI | | `shared` | Shared error definitions and utilities | TypeScript | | `mcp` | Development tooling (docs, linting) | Node.js | diff --git a/docs/architecture/diagrams/02-system-architecture.md b/docs/architecture/diagrams/02-system-architecture.md index 057772f94..edb755873 100644 --- a/docs/architecture/diagrams/02-system-architecture.md +++ b/docs/architecture/diagrams/02-system-architecture.md @@ -44,7 +44,7 @@ flowchart TB ### Frontend (SolidJS) -- **UI Components**: Zag.js-based accessible components +- **UI Components**: Ark UI-based accessible components - **Stores**: Centralized state management (no prop drilling) - **Yjs Client**: CRDT sync with local IndexedDB persistence for project content - **Notification WebSocket**: Real-time connection to UserSession for user-level notifications (project invites, etc.) diff --git a/docs/architecture/diagrams/07-api-actions.md b/docs/architecture/diagrams/07-api-actions.md new file mode 100644 index 000000000..054f9e4ed --- /dev/null +++ b/docs/architecture/diagrams/07-api-actions.md @@ -0,0 +1,725 @@ +# API Actions and Failure Points + +Comprehensive diagrams showing all actions that can be performed on projects, studies, and checklists, including all failure points and error conditions. + +## Overview + +This document visualizes the complete API surface for the three main entities in CoRATES: + +- **Projects**: Backend API routes + Y.js sync operations +- **Studies**: Y.js operations + PDF management via backend API +- **Checklists**: Y.js operations only + +Each diagram shows: + +- All CRUD operations +- Permission and validation checks +- External dependencies (D1, R2, Durable Objects) +- All failure points with error codes +- Network and sync failures + +--- + +## Project Actions + +Projects are managed through both HTTP API endpoints and Y.js synchronization. The backend API handles CRUD operations and member management, while Y.js syncs metadata and member changes to Durable Objects. + +```mermaid +flowchart TB + Start([User Action]) --> Auth{Authenticated?} + Auth -->|No| AuthError[AUTH_REQUIRED
401] + Auth -->|Yes| Action{Action Type} + + Action -->|Create| CreateFlow + Action -->|Read| ReadFlow + Action -->|Update| UpdateFlow + Action -->|Delete| DeleteFlow + Action -->|Member| MemberFlow + + subgraph CreateFlow["Create Project"] + CreateStart[POST /api/projects] --> Entitlement{Has project.create
entitlement?} + Entitlement -->|No| EntitlementError[AUTH_FORBIDDEN
403
missing_entitlement] + Entitlement -->|Yes| QuotaCheck{Under projects.max
quota?} + QuotaCheck -->|No| QuotaError[AUTH_FORBIDDEN
403
quota_exceeded] + QuotaCheck -->|Yes| ValidateInput{Valid name &
description?} + ValidateInput -->|No| ValidationError[VALIDATION_ERRORS
400] + ValidateInput -->|Yes| CreateDB[Insert into D1
projects + projectMembers] + CreateDB -->|DB Error| DBError[SYSTEM_DB_TRANSACTION_FAILED
500] + CreateDB -->|Success| SyncDO[Sync to Durable Object
meta + members] + SyncDO -->|Sync Failed| SyncError[Log error
Continue] + SyncDO -->|Success| CreateSuccess[201 Created
Return project] + end + + subgraph ReadFlow["Read Project"] + ReadStart[GET /api/projects/:id] --> CheckMembership{User is
member?} + CheckMembership -->|No| NotFoundError[PROJECT_NOT_FOUND
404] + CheckMembership -->|Yes| QueryDB[Query D1
projects + projectMembers] + QueryDB -->|DB Error| ReadDBError[SYSTEM_DB_ERROR
500] + QueryDB -->|Success| ReadSuccess[200 OK
Return project] + end + + subgraph UpdateFlow["Update Project"] + UpdateStart[PUT /api/projects/:id] --> ValidateUpdate{Valid name &
description?} + ValidateUpdate -->|No| UpdateValidationError[VALIDATION_ERRORS
400] + ValidateUpdate -->|Yes| CheckEditRole{User has edit
role? owner/collaborator} + CheckEditRole -->|No| UpdateForbidden[AUTH_FORBIDDEN
403
Only owners and collaborators] + CheckEditRole -->|Yes| UpdateDB[Update D1 projects] + UpdateDB -->|DB Error| UpdateDBError[SYSTEM_DB_ERROR
500] + UpdateDB -->|Success| UpdateSyncDO[Sync meta to DO] + UpdateSyncDO -->|Sync Failed| UpdateSyncError[Log error
Continue] + UpdateSyncDO -->|Success| UpdateSuccess[200 OK
Success response] + end + + subgraph DeleteFlow["Delete Project"] + DeleteStart[DELETE /api/projects/:id] --> CheckOwner{User is
owner?} + CheckOwner -->|No| DeleteForbidden[AUTH_FORBIDDEN
403
Only owners can delete] + CheckOwner -->|Yes| GetMembers[Get all members
for notifications] + GetMembers -->|DB Error| DeleteDBError1[SYSTEM_DB_ERROR
500] + GetMembers -->|Success| DisconnectDO[Disconnect all users
from ProjectDoc DO] + DisconnectDO -->|Failed| DisconnectError[Log error
Continue] + DisconnectDO -->|Success| CleanupR2[Delete all PDFs
from R2 storage] + CleanupR2 -->|Failed| R2Error[Log error
Continue] + CleanupR2 -->|Success| DeleteDB[Delete from D1
projects cascade] + DeleteDB -->|DB Error| DeleteDBError2[SYSTEM_DB_ERROR
500] + DeleteDB -->|Success| NotifyMembers[Send notifications
to all members] + NotifyMembers -->|Some Failed| NotifyError[Log errors
Continue] + NotifyMembers -->|Success| DeleteSuccess[200 OK
Success response] + end + + subgraph MemberFlow["Member Management"] + MemberAction{Member Action} + MemberAction -->|List| ListMembers[GET /api/projects/:id/members] + MemberAction -->|Add| AddMemberFlow + MemberAction -->|Update Role| UpdateRoleFlow + MemberAction -->|Remove| RemoveMemberFlow + + ListMembers --> ListDB[Query D1
projectMembers + user] + ListDB -->|DB Error| ListDBError[SYSTEM_DB_ERROR
500] + ListDB -->|Success| ListSuccess[200 OK
Return members] + + subgraph AddMemberFlow["Add Member"] + AddStart[POST /api/projects/:id/members] --> CheckOwnerAdd{User is
owner?} + CheckOwnerAdd -->|No| AddForbidden[AUTH_FORBIDDEN
403
Only owners can add] + CheckOwnerAdd -->|Yes| ValidateMember{Valid userId
or email?} + ValidateMember -->|No| MemberValidationError[VALIDATION_ERRORS
400] + ValidateMember -->|Yes| FindUser[Find user by
userId or email] + FindUser -->|Not Found| UserNotFound[USER_NOT_FOUND
404] + FindUser -->|Found| CheckExisting{Already
member?} + CheckExisting -->|Yes| MemberExists[PROJECT_MEMBER_ALREADY_EXISTS
409] + CheckExisting -->|No| InsertMember[Insert into D1
projectMembers] + InsertMember -->|DB Error| AddDBError[SYSTEM_DB_ERROR
500] + InsertMember -->|Success| NotifyUser[Send notification
via UserSession DO] + NotifyUser -->|Failed| NotifyUserError[Log error
Continue] + NotifyUser -->|Success| SyncMemberDO[Sync member to DO] + SyncMemberDO -->|Failed| SyncMemberError[Log error
Continue] + SyncMemberDO -->|Success| AddSuccess[201 Created
Return member] + end + + subgraph UpdateRoleFlow["Update Member Role"] + UpdateRoleStart[PUT /api/projects/:id/members/:userId] --> CheckOwnerRole{User is
owner?} + CheckOwnerRole -->|No| UpdateRoleForbidden[AUTH_FORBIDDEN
403
Only owners can update] + CheckOwnerRole -->|Yes| ValidateRole{Valid role?} + ValidateRole -->|No| RoleValidationError[VALIDATION_ERRORS
400] + ValidateRole -->|Yes| CheckLastOwner{Removing last
owner?} + CheckLastOwner -->|Yes| LastOwnerError[PROJECT_LAST_OWNER
400
Assign another owner first] + CheckLastOwner -->|No| UpdateRoleDB[Update D1
projectMembers.role] + UpdateRoleDB -->|DB Error| UpdateRoleDBError[SYSTEM_DB_ERROR
500] + UpdateRoleDB -->|Success| SyncRoleDO[Sync role to DO] + SyncRoleDO -->|Failed| SyncRoleError[Log error
Continue] + SyncRoleDO -->|Success| UpdateRoleSuccess[200 OK
Success response] + end + + subgraph RemoveMemberFlow["Remove Member"] + RemoveStart[DELETE /api/projects/:id/members/:userId] --> CheckRemoveAuth{User is owner
or self-removal?} + CheckRemoveAuth -->|No| RemoveForbidden[AUTH_FORBIDDEN
403
Only owners can remove] + CheckRemoveAuth -->|Yes| CheckTargetExists{Target member
exists?} + CheckTargetExists -->|No| RemoveNotFound[PROJECT_NOT_FOUND
404
Member not found] + CheckTargetExists -->|Yes| CheckRemoveLastOwner{Removing last
owner?} + CheckRemoveLastOwner -->|Yes| RemoveLastOwnerError[PROJECT_LAST_OWNER
400
Assign another owner first] + CheckRemoveLastOwner -->|No| RemoveDB[Delete from D1
projectMembers] + RemoveDB -->|DB Error| RemoveDBError[SYSTEM_DB_ERROR
500] + RemoveDB -->|Success| SyncRemoveDO[Sync removal to DO
forces disconnect] + SyncRemoveDO -->|Failed| SyncRemoveError[Log error
Continue] + SyncRemoveDO -->|Success| NotifyRemoved{Self-removal?} + NotifyRemoved -->|Yes| RemoveSuccess[200 OK
Success response] + NotifyRemoved -->|No| NotifyRemovedUser[Send notification
via UserSession DO] + NotifyRemovedUser -->|Failed| NotifyRemovedError[Log error
Continue] + NotifyRemovedUser -->|Success| RemoveSuccess + end + end + + style AuthError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style EntitlementError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style QuotaError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ValidationError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DBError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style SyncError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NotFoundError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ReadDBError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateValidationError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateForbidden fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateDBError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateSyncError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteForbidden fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteDBError1 fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteDBError2 fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DisconnectError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style R2Error fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NotifyError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AddForbidden fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style MemberValidationError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UserNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style MemberExists fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AddDBError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NotifyUserError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style SyncMemberError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateRoleForbidden fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style RoleValidationError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style LastOwnerError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateRoleDBError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style SyncRoleError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style RemoveForbidden fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style RemoveNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style RemoveLastOwnerError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style RemoveDBError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style SyncRemoveError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NotifyRemovedError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff +``` + +--- + +## Study Actions + +Studies are managed entirely through Y.js operations (no direct backend API). PDFs are managed via backend API routes. Studies support metadata extraction, DOI lookups, and Google Drive imports. + +```mermaid +flowchart TB + Start([User Action]) --> Connected{Connected to
project?} + Connected -->|No| ConnectionError[Not connected to project
Show toast error] + Connected -->|Yes| Synced{Synced with
Y.js?} + Synced -->|No| SyncError[Y.js not synced
Operation fails] + Synced -->|Yes| StudyAction{Study Action} + + StudyAction -->|Create| CreateStudyFlow + StudyAction -->|Update| UpdateStudyFlow + StudyAction -->|Delete| DeleteStudyFlow + StudyAction -->|PDF| PDFFlow + StudyAction -->|Import| ImportFlow + + subgraph CreateStudyFlow["Create Study"] + CreateStudy[createStudy name, description, metadata] --> CheckYDoc{Y.Doc
available?} + CheckYDoc -->|No| CreateYDocError[No Y.Doc
Return null] + CheckYDoc -->|Yes| GenerateId[Generate studyId
crypto.randomUUID] + GenerateId --> CreateYMap[Create Y.Map for study
Set name, description,
createdAt, updatedAt,
checklists: new Y.Map] + CreateYMap --> SetMetadata[Set optional metadata:
originalTitle, firstAuthor,
publicationYear, authors,
journal, doi, abstract, etc.] + SetMetadata --> AddToMap[Add to reviews Y.Map
studiesMap.set studyId] + AddToMap -->|Y.js Error| YjsError[Y.js operation failed
Return null] + AddToMap -->|Success| CreateSuccess[Return studyId] + end + + subgraph UpdateStudyFlow["Update Study"] + UpdateStudy[updateStudy studyId, updates] --> CheckYDocUpdate{Y.Doc
available?} + CheckYDocUpdate -->|No| UpdateYDocError[No Y.Doc
Return] + CheckYDocUpdate -->|Yes| GetStudyMap[Get study from
reviews Y.Map] + GetStudyMap -->|Not Found| StudyNotFound[Study not found
Return] + GetStudyMap -->|Found| UpdateFields[Update fields:
name, description, metadata
Set updatedAt] + UpdateFields -->|Y.js Error| UpdateYjsError[Y.js operation failed
Show toast error] + UpdateFields -->|Success| UpdateSuccess[Update complete] + end + + subgraph DeleteStudyFlow["Delete Study"] + DeleteStudy[deleteStudy studyId] --> CheckYDocDelete{Y.Doc
available?} + CheckYDocDelete -->|No| DeleteYDocError[No Y.Doc
Show toast error] + CheckYDocDelete -->|Yes| GetStudyData[Get study data
from projectStore] + GetStudyData -->|Not Found| DeleteNotFound[Study not found
Show toast error] + GetStudyData -->|Found| GetPDFs[Get all PDFs
for study] + GetPDFs --> DeletePDFs[Delete each PDF
from R2 storage] + DeletePDFs -->|Some Failed| PDFDeleteError[Log warnings
Continue] + DeletePDFs -->|Success| ClearCache[Clear PDF cache
from IndexedDB] + ClearCache -->|Failed| CacheError[Log warning
Continue] + ClearCache -->|Success| DeleteFromYjs[Delete from
reviews Y.Map] + DeleteFromYjs -->|Y.js Error| DeleteYjsError[Y.js operation failed
Show toast error] + DeleteFromYjs -->|Success| DeleteSuccess[Study deleted] + end + + subgraph PDFFlow["PDF Operations"] + PDFAction{PDF Action} + PDFAction -->|Upload| UploadPDFFlow + PDFAction -->|Download| DownloadPDFFlow + PDFAction -->|Delete| DeletePDFFlow + PDFAction -->|List| ListPDFFlow + + subgraph UploadPDFFlow["Upload PDF"] + UploadStart[POST /api/projects/:id/studies/:id/pdfs] --> CheckAuth{Authenticated?} + CheckAuth -->|No| UploadAuthError[AUTH_REQUIRED
401] + CheckAuth -->|Yes| CheckMembership{User is
member?} + CheckMembership -->|No| UploadAccessError[PROJECT_ACCESS_DENIED
403] + CheckMembership -->|Yes| CheckRole{User role is
viewer?} + CheckRole -->|Yes| UploadForbidden[AUTH_FORBIDDEN
403
Insufficient permissions] + CheckRole -->|No| CheckSize{File size
check} + CheckSize -->|Too Large| SizeError[FILE_TOO_LARGE
413
Exceeds limit] + CheckSize -->|OK| ValidateFile{Valid PDF
magic bytes?} + ValidateFile -->|No| InvalidTypeError[FILE_INVALID_TYPE
400
Not a valid PDF] + ValidateFile -->|Yes| ValidateFileName{Valid file
name?} + ValidateFileName -->|No| FileNameError[VALIDATION_FIELD_INVALID_FORMAT
400
Invalid file name] + ValidateFileName -->|Yes| CheckDuplicate{File already
exists?} + CheckDuplicate -->|Yes| DuplicateError[FILE_ALREADY_EXISTS
409] + CheckDuplicate -->|No| UploadR2[Upload to R2
PDF_BUCKET.put] + UploadR2 -->|R2 Error| R2UploadError[FILE_UPLOAD_FAILED
500] + UploadR2 -->|Success| ExtractMetadata[Extract PDF metadata:
title, DOI, etc.] + ExtractMetadata -->|Failed| MetadataError[Log warning
Continue] + ExtractMetadata -->|Success| FetchDOIMetadata{DOI found?} + FetchDOIMetadata -->|Yes| LookupDOI[Fetch from DOI API] + LookupDOI -->|Network Error| DOINetworkError[Network error
Log and continue] + LookupDOI -->|Success| AddToStudy[Add PDF metadata
to study via Y.js] + FetchDOIMetadata -->|No| AddToStudy + AddToStudy -->|Y.js Error| AddYjsError[Y.js operation failed
Rollback: delete PDF] + AddToStudy -->|Success| CachePDF[Cache PDF
in IndexedDB] + CachePDF -->|Failed| CachePDFError[Log warning
Continue] + CachePDF -->|Success| UploadSuccess[200 OK
Return PDF info] + end + + subgraph DownloadPDFFlow["Download PDF"] + DownloadStart[GET /api/projects/:id/studies/:id/pdfs/:fileName] --> CheckAuthDownload{Authenticated?} + CheckAuthDownload -->|No| DownloadAuthError[AUTH_REQUIRED
401] + CheckAuthDownload -->|Yes| CheckMembershipDownload{User is
member?} + CheckMembershipDownload -->|No| DownloadAccessError[PROJECT_ACCESS_DENIED
403] + CheckMembershipDownload -->|Yes| ValidateFileNameDownload{Valid file
name?} + ValidateFileNameDownload -->|No| FileNameDownloadError[VALIDATION_FIELD_INVALID_FORMAT
400] + ValidateFileNameDownload -->|Yes| GetR2[Get from R2
PDF_BUCKET.get] + GetR2 -->|Not Found| NotFoundError[FILE_NOT_FOUND
404] + GetR2 -->|R2 Error| R2DownloadError[SYSTEM_INTERNAL_ERROR
500] + GetR2 -->|Success| DownloadSuccess[200 OK
Return PDF binary] + end + + subgraph DeletePDFFlow["Delete PDF"] + DeletePDFStart[DELETE /api/projects/:id/studies/:id/pdfs/:fileName] --> CheckAuthDelete{Authenticated?} + CheckAuthDelete -->|No| DeleteAuthError[AUTH_REQUIRED
401] + CheckAuthDelete -->|Yes| CheckMembershipDelete{User is
member?} + CheckMembershipDelete -->|No| DeleteAccessError[PROJECT_ACCESS_DENIED
403] + CheckMembershipDelete -->|Yes| CheckRoleDelete{User role is
viewer?} + CheckRoleDelete -->|Yes| DeleteForbidden[AUTH_FORBIDDEN
403
Insufficient permissions] + CheckRoleDelete -->|No| ValidateFileNameDelete{Valid file
name?} + ValidateFileNameDelete -->|No| FileNameDeleteError[VALIDATION_FIELD_INVALID_FORMAT
400] + ValidateFileNameDelete -->|Yes| DeleteR2[Delete from R2
PDF_BUCKET.delete] + DeleteR2 -->|R2 Error| R2DeleteError[SYSTEM_INTERNAL_ERROR
500] + DeleteR2 -->|Success| DeletePDFSuccess[200 OK
Success response] + end + + subgraph ListPDFFlow["List PDFs"] + ListPDFStart[GET /api/projects/:id/studies/:id/pdfs] --> CheckAuthList{Authenticated?} + CheckAuthList -->|No| ListAuthError[AUTH_REQUIRED
401] + CheckAuthList -->|Yes| CheckMembershipList{User is
member?} + CheckMembershipList -->|No| ListAccessError[PROJECT_ACCESS_DENIED
403] + CheckMembershipList -->|Yes| ListR2[List from R2
PDF_BUCKET.list] + ListR2 -->|R2 Error| R2ListError[SYSTEM_INTERNAL_ERROR
500] + ListR2 -->|Success| ListSuccess[200 OK
Return PDF list] + end + end + + subgraph ImportFlow["Import Operations"] + ImportAction{Import Type} + ImportAction -->|Google Drive| GoogleDriveFlow + ImportAction -->|DOI Lookup| DOIFlow + ImportAction -->|Reference File| ReferenceFileFlow + + subgraph GoogleDriveFlow["Google Drive Import"] + GDriveStart[importFromGoogleDrive fileId] --> CheckAuthGDrive{Authenticated?} + CheckAuthGDrive -->|No| GDriveAuthError[AUTH_REQUIRED
401] + CheckAuthGDrive -->|Yes| FetchGDrive[Fetch file from
Google Drive API] + FetchGDrive -->|Network Error| GDriveNetworkError[Network error
Show toast error] + FetchGDrive -->|API Error| GDriveAPIError[Google Drive API error
Show toast error] + FetchGDrive -->|Success| UploadGDrive[Upload to R2
via PDF upload flow] + UploadGDrive -->|Failed| GDriveUploadError[Upload failed
Show toast error] + UploadGDrive -->|Success| ExtractGDriveMetadata[Extract metadata
from PDF] + ExtractGDriveMetadata -->|Failed| GDriveMetadataError[Log warning
Continue] + ExtractGDriveMetadata -->|Success| UpdateStudyGDrive[Update study
with metadata] + UpdateStudyGDrive -->|Y.js Error| GDriveYjsError[Y.js operation failed
Show toast error] + UpdateStudyGDrive -->|Success| GDriveSuccess[Import complete] + end + + subgraph DOIFlow["DOI Lookup"] + DOIStart[fetchFromDOI doi] --> ValidateDOI{Valid DOI
format?} + ValidateDOI -->|No| DOIValidationError[Invalid DOI
Return null] + ValidateDOI -->|Yes| FetchDOIAPI[Fetch from DOI
API external] + FetchDOIAPI -->|Network Error| DOINetworkError[Network error
Log warning
Return null] + FetchDOIAPI -->|API Error| DOIAPIError[DOI API error
Log warning
Return null] + FetchDOIAPI -->|Success| ParseDOIResponse[Parse response:
firstAuthor, publicationYear,
authors, journal, abstract] + ParseDOIResponse -->|Parse Error| DOIParseError[Parse error
Log warning
Return null] + ParseDOIResponse -->|Success| DOISuccess[Return metadata] + end + + subgraph ReferenceFileFlow["Reference File Import"] + RefFileStart[importReferences references] --> CheckConnectionRef{Connected to
project?} + CheckConnectionRef -->|No| RefConnectionError[Not connected
Show toast error
Return 0] + CheckConnectionRef -->|Yes| LoopRefs[For each reference] + LoopRefs --> CreateRefStudy[Create study from
reference data] + CreateRefStudy -->|Y.js Error| RefYjsError[Y.js operation failed
Log error
Continue] + CreateRefStudy -->|Success| IncrementCount[Increment success count] + IncrementCount --> MoreRefs{More
references?} + MoreRefs -->|Yes| LoopRefs + MoreRefs -->|No| RefSuccess[Show toast
Return count] + end + end + + style ConnectionError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style SyncError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style CreateYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style YjsError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style StudyNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateYjsError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style PDFDeleteError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style CacheError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteYjsError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UploadAuthError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UploadAccessError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UploadForbidden fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style SizeError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style InvalidTypeError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style FileNameError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DuplicateError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style R2UploadError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style MetadataError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DOINetworkError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AddYjsError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style CachePDFError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DownloadAuthError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DownloadAccessError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style FileNameDownloadError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NotFoundError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style R2DownloadError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteAuthError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteAccessError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteForbidden fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style FileNameDeleteError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style R2DeleteError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ListAuthError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ListAccessError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style R2ListError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style GDriveAuthError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style GDriveNetworkError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style GDriveAPIError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style GDriveUploadError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style GDriveMetadataError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style GDriveYjsError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DOIValidationError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DOINetworkError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DOIAPIError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DOIParseError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style RefConnectionError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style RefYjsError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff +``` + +--- + +## Checklist Actions + +Checklists are managed entirely through Y.js operations (no backend API). Checklists support multiple types (AMSTAR2, ROBINS-I) with different answer structures. Answers are stored as nested Y.Maps for concurrent editing support. + +```mermaid +flowchart TB + Start([User Action]) --> Connected{Connected to
project?} + Connected -->|No| ConnectionError[Not connected to project
Show toast error] + Connected -->|Yes| Synced{Synced with
Y.js?} + Synced -->|No| SyncError[Y.js not synced
Operation fails] + Synced -->|Yes| ChecklistAction{Checklist Action} + + ChecklistAction -->|Create| CreateChecklistFlow + ChecklistAction -->|Update| UpdateChecklistFlow + ChecklistAction -->|Delete| DeleteChecklistFlow + ChecklistAction -->|Update Answer| UpdateAnswerFlow + ChecklistAction -->|Get Data| GetDataFlow + ChecklistAction -->|Get Note| GetNoteFlow + + subgraph CreateChecklistFlow["Create Checklist"] + CreateChecklist[createChecklist studyId, type, assigneeId] --> CheckYDoc{Y.Doc
available?} + CheckYDoc -->|No| CreateYDocError[No Y.Doc
Show toast error
Return false] + CheckYDoc -->|Yes| GetStudy[Get study from
reviews Y.Map] + GetStudy -->|Not Found| StudyNotFound[Study not found
Return null] + GetStudy -->|Found| ValidateType{Valid checklist
type? AMSTAR2/ROBINS-I} + ValidateType -->|No| InvalidTypeError[Invalid checklist type
Return null] + ValidateType -->|Yes| GetChecklistsMap[Get checklists Y.Map
from study] + GetChecklistsMap -->|Not Exists| CreateChecklistsMap[Create new Y.Map
for checklists] + CreateChecklistsMap -->|Y.js Error| CreateMapError[Y.js operation failed
Return null] + CreateChecklistsMap -->|Success| GenerateChecklistId[Generate checklistId
crypto.randomUUID] + GetChecklistsMap -->|Exists| GenerateChecklistId + GenerateChecklistId --> GetTemplate[Get checklist template
from CHECKLIST_REGISTRY] + GetTemplate -->|Type Not Found| TemplateError[Checklist type not in registry
Return null] + GetTemplate -->|Found| ExtractAnswers[Extract default answers
structure from template] + ExtractAnswers -->|Extract Error| ExtractError[Failed to extract answers
Return null] + ExtractAnswers -->|Success| CreateChecklistYMap[Create checklist Y.Map:
type, title, assignedTo,
status: pending,
isReconciled: false,
createdAt, updatedAt] + CreateChecklistYMap --> CreateAnswersMap[Create answers Y.Map
Structure depends on type] + CreateAnswersMap -->|AMSTAR2| CreateAMSTAR2Answers[Create nested Y.Maps
for each question q1-q16
with answers, critical, note] + CreateAMSTAR2Answers -->|Y.js Error| AMSTAR2Error[Y.js operation failed
Return null] + CreateAMSTAR2Answers -->|Success| AddToChecklistsMap[Add to checklists Y.Map
checklistsMap.set checklistId] + CreateAnswersMap -->|ROBINS-I| CreateROBINSAnswers[Create nested Y.Maps
for domains, sections
with judgement, direction,
answers nested structure] + CreateROBINSAnswers -->|Y.js Error| ROBINSError[Y.js operation failed
Return null] + CreateROBINSAnswers -->|Success| AddToChecklistsMap + CreateAnswersMap -->|Other| CreateOtherAnswers[Store data directly
as JSON] + CreateOtherAnswers -->|Y.js Error| OtherError[Y.js operation failed
Return null] + CreateOtherAnswers -->|Success| AddToChecklistsMap + AddToChecklistsMap -->|Y.js Error| AddMapError[Y.js operation failed
Return null] + AddToChecklistsMap -->|Success| UpdateStudyTimestamp[Update study
updatedAt timestamp] + UpdateStudyTimestamp -->|Y.js Error| TimestampError[Y.js operation failed
Log error
Continue] + UpdateStudyTimestamp -->|Success| CreateSuccess[Return checklistId] + end + + subgraph UpdateChecklistFlow["Update Checklist"] + UpdateChecklist[updateChecklist studyId, checklistId, updates] --> CheckYDocUpdate{Y.Doc
available?} + CheckYDocUpdate -->|No| UpdateYDocError[No Y.Doc
Show toast error] + CheckYDocUpdate -->|Yes| GetStudyUpdate[Get study from
reviews Y.Map] + GetStudyUpdate -->|Not Found| UpdateStudyNotFound[Study not found
Return] + GetStudyUpdate -->|Found| GetChecklistsMapUpdate[Get checklists Y.Map] + GetChecklistsMapUpdate -->|Not Exists| UpdateChecklistsMapError[Checklists map not found
Return] + GetChecklistsMapUpdate -->|Exists| GetChecklist[Get checklist Y.Map] + GetChecklist -->|Not Found| UpdateChecklistNotFound[Checklist not found
Return] + GetChecklist -->|Found| UpdateFields[Update fields:
title, assignedTo,
status, isReconciled
Set updatedAt] + UpdateFields -->|Y.js Error| UpdateYjsError[Y.js operation failed
Show toast error] + UpdateFields -->|Success| UpdateSuccess[Update complete] + end + + subgraph DeleteChecklistFlow["Delete Checklist"] + DeleteChecklist[deleteChecklist studyId, checklistId] --> CheckYDocDelete{Y.Doc
available?} + CheckYDocDelete -->|No| DeleteYDocError[No Y.Doc
Show toast error] + CheckYDocDelete -->|Yes| GetStudyDelete[Get study from
reviews Y.Map] + GetStudyDelete -->|Not Found| DeleteStudyNotFound[Study not found
Return] + GetStudyDelete -->|Found| GetChecklistsMapDelete[Get checklists Y.Map] + GetChecklistsMapDelete -->|Not Exists| DeleteChecklistsMapError[Checklists map not found
Return] + GetChecklistsMapDelete -->|Exists| DeleteFromMap[Delete from
checklists Y.Map] + DeleteFromMap -->|Y.js Error| DeleteYjsError[Y.js operation failed
Show toast error] + DeleteFromMap -->|Success| UpdateStudyTimestampDelete[Update study
updatedAt timestamp] + UpdateStudyTimestampDelete -->|Y.js Error| DeleteTimestampError[Y.js operation failed
Log error
Continue] + UpdateStudyTimestampDelete -->|Success| DeleteSuccess[Checklist deleted] + end + + subgraph UpdateAnswerFlow["Update Checklist Answer"] + UpdateAnswer[updateChecklistAnswer studyId, checklistId, key, data] --> CheckYDocAnswer{Y.Doc
available?} + CheckYDocAnswer -->|No| AnswerYDocError[No Y.Doc
Return] + CheckYDocAnswer -->|Yes| GetStudyAnswer[Get study from
reviews Y.Map] + GetStudyAnswer -->|Not Found| AnswerStudyNotFound[Study not found
Return] + GetStudyAnswer -->|Found| GetChecklistsMapAnswer[Get checklists Y.Map] + GetChecklistsMapAnswer -->|Not Exists| AnswerChecklistsMapError[Checklists map not found
Return] + GetChecklistsMapAnswer -->|Exists| GetChecklistAnswer[Get checklist Y.Map] + GetChecklistAnswer -->|Not Found| AnswerChecklistNotFound[Checklist not found
Return] + GetChecklistAnswer -->|Found| GetAnswersMap[Get answers Y.Map
from checklist] + GetAnswersMap -->|Not Exists| CreateAnswersMapAnswer[Create new
answers Y.Map] + CreateAnswersMapAnswer -->|Y.js Error| CreateAnswersError[Y.js operation failed
Return] + CreateAnswersMapAnswer -->|Success| GetChecklistType[Get checklist type] + GetAnswersMap -->|Exists| GetChecklistType + GetChecklistType -->|AMSTAR2| UpdateAMSTAR2Answer[Update question Y.Map:
answers, critical
Preserve note Y.Text] + UpdateAMSTAR2Answer -->|Y.js Error| UpdateAMSTAR2Error[Y.js operation failed
Return] + UpdateAMSTAR2Answer -->|Success| CheckStatus[Check status] + GetChecklistType -->|ROBINS-I| UpdateROBINSAnswer[Update section Y.Map:
judgement, direction,
nested answers structure] + UpdateROBINSAnswer -->|Y.js Error| UpdateROBINSError[Y.js operation failed
Return] + UpdateROBINSAnswer -->|Success| CheckStatus + GetChecklistType -->|Other| UpdateOtherAnswer[Store data directly] + UpdateOtherAnswer -->|Y.js Error| UpdateOtherError[Y.js operation failed
Return] + UpdateOtherAnswer -->|Success| CheckStatus + CheckStatus -->|status === pending| SetInProgress[Set status to
in-progress] + SetInProgress -->|Y.js Error| StatusError[Y.js operation failed
Log error
Continue] + SetInProgress -->|Success| SetUpdatedAt[Set updatedAt
timestamp] + CheckStatus -->|status !== pending| SetUpdatedAt + SetUpdatedAt -->|Y.js Error| UpdatedAtError[Y.js operation failed
Log error
Continue] + SetUpdatedAt -->|Success| AnswerSuccess[Answer updated] + end + + subgraph GetDataFlow["Get Checklist Data"] + GetData[getChecklistData studyId, checklistId] --> CheckYDocData{Y.Doc
available?} + CheckYDocData -->|No| DataYDocError[No Y.Doc
Return null] + CheckYDocData -->|Yes| GetStudyData[Get study from
reviews Y.Map] + GetStudyData -->|Not Found| DataStudyNotFound[Study not found
Return null] + GetStudyData -->|Found| GetChecklistsMapData[Get checklists Y.Map] + GetChecklistsMapData -->|Not Exists| DataChecklistsMapError[Checklists map not found
Return null] + GetChecklistsMapData -->|Exists| GetChecklistData[Get checklist Y.Map] + GetChecklistData -->|Not Found| DataChecklistNotFound[Checklist not found
Return null] + GetChecklistData -->|Found| ConvertToJSON[Convert Y.Map to
plain object] + ConvertToJSON -->|Conversion Error| ConvertError[Conversion failed
Return null] + ConvertToJSON -->|Success| GetAnswers[Get answers Y.Map] + GetAnswers -->|Not Exists| DataAnswersNotFound[Answers map not found
Return data without answers] + GetAnswers -->|Exists| ReconstructAnswers[Reconstruct nested structure
from Y.Maps based on type] + ReconstructAnswers -->|AMSTAR2| ReconstructAMSTAR2[Convert question Y.Maps
to plain objects] + ReconstructAMSTAR2 -->|Error| ReconstructAMSTAR2Error[Reconstruction failed
Return partial data] + ReconstructAMSTAR2 -->|Success| MergeData[Merge with checklist data] + ReconstructAnswers -->|ROBINS-I| ReconstructROBINS[Convert domain/section Y.Maps
to nested structure] + ReconstructROBINS -->|Error| ReconstructROBINSError[Reconstruction failed
Return partial data] + ReconstructROBINS -->|Success| MergeData + ReconstructAnswers -->|Other| ReconstructOther[Convert directly] + ReconstructOther -->|Error| ReconstructOtherError[Reconstruction failed
Return partial data] + ReconstructOther -->|Success| MergeData + MergeData -->|Success| DataSuccess[Return checklist data] + end + + subgraph GetNoteFlow["Get Question Note"] + GetNote[getQuestionNote studyId, checklistId, questionKey] --> CheckYDocNote{Y.Doc
available?} + CheckYDocNote -->|No| NoteYDocError[No Y.Doc
Return null] + CheckYDocNote -->|Yes| GetStudyNote[Get study from
reviews Y.Map] + GetStudyNote -->|Not Found| NoteStudyNotFound[Study not found
Return null] + GetStudyNote -->|Found| GetChecklistsMapNote[Get checklists Y.Map] + GetChecklistsMapNote -->|Not Exists| NoteChecklistsMapError[Checklists map not found
Return null] + GetChecklistsMapNote -->|Exists| GetChecklistNote[Get checklist Y.Map] + GetChecklistNote -->|Not Found| NoteChecklistNotFound[Checklist not found
Return null] + GetChecklistNote -->|Found| GetAnswersMapNote[Get answers Y.Map] + GetAnswersMapNote -->|Not Exists| NoteAnswersNotFound[Answers map not found
Return null] + GetAnswersMapNote -->|Exists| GetQuestionMap[Get question Y.Map
by questionKey] + GetQuestionMap -->|Not Found| NoteQuestionNotFound[Question not found
Return null] + GetQuestionMap -->|Found| GetNoteYText[Get note Y.Text] + GetNoteYText -->|Not Exists| CreateNoteYText[Create new Y.Text
for backward compatibility] + CreateNoteYText -->|Not Synced| NoteNotSyncedError[Not synced
Return null] + CreateNoteYText -->|Synced| NoteSuccess[Return Y.Text] + GetNoteYText -->|Exists| NoteSuccess + end + + style ConnectionError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style SyncError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style CreateYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style StudyNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style InvalidTypeError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style CreateMapError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style TemplateError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ExtractError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AMSTAR2Error fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ROBINSError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style OtherError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AddMapError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style TimestampError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateStudyNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateChecklistsMapError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateChecklistNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateYjsError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteStudyNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteChecklistsMapError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteYjsError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DeleteTimestampError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AnswerYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AnswerStudyNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AnswerChecklistsMapError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style AnswerChecklistNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style CreateAnswersError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateAMSTAR2Error fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateROBINSError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdateOtherError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style StatusError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style UpdatedAtError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DataYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DataStudyNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DataChecklistsMapError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DataChecklistNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ConvertError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style DataAnswersNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ReconstructAMSTAR2Error fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ReconstructROBINSError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style ReconstructOtherError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NoteYDocError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NoteStudyNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NoteChecklistsMapError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NoteChecklistNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NoteAnswersNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NoteQuestionNotFound fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff + style NoteNotSyncedError fill:#dc3545,stroke:#991f2e,stroke-width:2px,color:#ffffff +``` + +--- + +## Failure Points Legend + +### Error Code Categories + +#### Authentication Errors (AUTH_ERRORS) + +- **AUTH_REQUIRED** (401): User not authenticated +- **AUTH_INVALID** (401): Invalid credentials +- **AUTH_EXPIRED** (401): Session expired +- **AUTH_FORBIDDEN** (403): Insufficient permissions or missing entitlement + +#### Validation Errors (VALIDATION_ERRORS) + +- **FIELD_REQUIRED** (400): Required field missing +- **FIELD_INVALID_FORMAT** (400): Field format invalid +- **FIELD_TOO_LONG** (400): Field exceeds maximum length +- **FIELD_TOO_SHORT** (400): Field below minimum length +- **MULTI_FIELD** (400): Multiple validation errors +- **FAILED** (400): General validation failure +- **INVALID_INPUT** (400): Invalid input data + +#### Project Errors (PROJECT_ERRORS) + +- **NOT_FOUND** (404): Project, member, or resource not found +- **ACCESS_DENIED** (403): User does not have access to project +- **MEMBER_ALREADY_EXISTS** (409): User is already a member +- **LAST_OWNER** (400): Cannot remove last owner +- **INVALID_ROLE** (400): Invalid role specified + +#### File Errors (FILE_ERRORS) + +- **TOO_LARGE** (413): File exceeds size limit +- **INVALID_TYPE** (400): Invalid file type (not PDF) +- **NOT_FOUND** (404): File not found in R2 +- **UPLOAD_FAILED** (500): File upload to R2 failed +- **ALREADY_EXISTS** (409): File with same name already exists + +#### User Errors (USER_ERRORS) + +- **NOT_FOUND** (404): User not found +- **EMAIL_NOT_VERIFIED** (403): Email address not verified + +#### System Errors (SYSTEM_ERRORS) + +- **DB_ERROR** (500): Database operation failed +- **DB_TRANSACTION_FAILED** (500): Database transaction failed +- **EMAIL_SEND_FAILED** (500): Failed to send email +- **EMAIL_INVALID** (400): Invalid email address +- **RATE_LIMITED** (429): Too many requests +- **INTERNAL_ERROR** (500): Internal server error +- **SERVICE_UNAVAILABLE** (503): Service temporarily unavailable + +### HTTP Status Codes + +- **200 OK**: Successful operation +- **201 Created**: Resource created successfully +- **400 Bad Request**: Validation error or business logic error +- **401 Unauthorized**: Authentication required or invalid +- **403 Forbidden**: Insufficient permissions +- **404 Not Found**: Resource not found +- **409 Conflict**: Resource conflict (e.g., duplicate) +- **413 Payload Too Large**: File size exceeds limit +- **429 Too Many Requests**: Rate limit exceeded +- **500 Internal Server Error**: Server error +- **503 Service Unavailable**: Service temporarily unavailable + +### Y.js Sync Failures + +Y.js operations can fail in several ways: + +- **Not Connected**: No active Y.js connection to project +- **Not Synced**: Y.js document not yet synced with server +- **Y.Doc Missing**: Y.Doc not available (null/undefined) +- **Y.js Operation Error**: Y.js map/text operation throws error +- **Sync Conflict**: Concurrent edits cause sync issues + +### Network Failures + +External API calls can fail: + +- **Network Error**: Connection timeout, DNS failure, etc. +- **API Error**: External API returns error response +- **Parse Error**: Failed to parse API response +- **Timeout**: Request exceeded timeout limit + +### Permission Levels + +- **Owner**: Full access, can delete project, manage all members +- **Collaborator**: Can edit content, cannot delete project or manage members +- **Member**: Can edit content, cannot delete project or manage members +- **Viewer**: Read-only access, cannot edit or upload + +### External Dependencies + +- **D1 Database**: Cloudflare D1 SQL database for persistent storage +- **R2 Storage**: Cloudflare R2 object storage for PDFs +- **Durable Objects**: ProjectDoc and UserSession for real-time sync +- **External APIs**: DOI lookup, Google Drive API diff --git a/docs/architecture/index.html b/docs/architecture/index.html index 344df682b..8b154d823 100644 --- a/docs/architecture/index.html +++ b/docs/architecture/index.html @@ -12,6 +12,17 @@ startOnLoad: false, theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default', securityLevel: 'loose', + flowchart: { + useMaxWidth: false, + htmlLabels: true, + curve: 'basis', + }, + gantt: { + useMaxWidth: false, + }, + sequence: { + useMaxWidth: false, + }, }); const diagrams = [ @@ -21,6 +32,7 @@ { id: 'data', file: '04-data-model.md', title: 'Data Model' }, { id: 'routes', file: '05-frontend-routes.md', title: 'Frontend Routes' }, { id: 'api', file: '06-api-routes.md', title: 'API Routes' }, + { id: 'actions', file: '07-api-actions.md', title: 'API Actions' }, ]; async function loadDiagrams() { @@ -59,6 +71,13 @@ // Render all mermaid diagrams await mermaid.run({ querySelector: '.language-mermaid' }); + + // Scale up diagrams after rendering + document.querySelectorAll('.diagram-container svg').forEach(svg => { + svg.style.minWidth = '1200px'; + svg.style.width = '100%'; + svg.style.height = 'auto'; + }); } // Custom renderer to handle mermaid code blocks @@ -96,13 +115,17 @@ } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - max-width: 1200px; - margin: 0 auto; + max-width: 100%; + margin: 0; padding: 2rem; background: var(--bg); color: var(--text); line-height: 1.6; } + .content-wrapper { + max-width: 1800px; + margin: 0 auto; + } h1 { border-bottom: 2px solid var(--accent); padding-bottom: 0.5rem; @@ -111,20 +134,55 @@ h2 { margin-top: 2rem; color: var(--accent); + font-size: 1.8rem; + } + section { + width: 100%; + max-width: 100%; } .diagram-container { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; - padding: 1.5rem; - margin: 1rem 0 2rem; + padding: 2rem; + margin: 2rem 0 3rem; overflow-x: auto; + overflow-y: visible; + min-height: 400px; + width: 100%; + position: relative; } .language-mermaid { - display: flex; - justify-content: center; + display: block; background: transparent; margin: 0; + width: 100%; + min-width: 100%; + } + .language-mermaid svg { + max-width: none !important; + width: 100% !important; + height: auto !important; + min-width: 1200px; + font-size: 14px; + } + .diagram-container svg .nodeLabel, + .diagram-container svg .edgeLabel { + font-size: 14px !important; + } + .diagram-container::-webkit-scrollbar { + height: 12px; + } + .diagram-container::-webkit-scrollbar-track { + background: var(--card-bg); + border-radius: 6px; + } + .diagram-container::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 6px; + } + .diagram-container::-webkit-scrollbar-thumb:hover { + background: var(--accent); } p { color: var(--text); @@ -197,6 +255,8 @@

CoRATES Architecture Diagrams

-
+
+
+
diff --git a/package.json b/package.json index a746240b4..7cd85c9ea 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@eslint/js": "^9.39.2", "@typescript-eslint/parser": "^8.50.0", "concurrently": "^9.2.1", + "dotenv": "^17.2.3", "eslint": "^9.39.2", "eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-solid": "^0.14.5", @@ -58,10 +59,5 @@ "engines": { "node": ">=24.0.0", "pnpm": ">=10.0.0" - }, - "dependencies": { - "@zag-js/clipboard": "^1.31.1", - "@zag-js/combobox": "^1.31.1", - "@zag-js/tour": "^1.31.1" } } diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5ff4862ef..7723d8581 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -51,7 +51,7 @@ Fetch Drizzle ORM documentation. ### `zag_docs` -Fetch Zag.js documentation for building accessible UI components with SolidJS. +Fetch Zag.js documentation for building accessible UI components with SolidJS. Note: Most components have been migrated to Ark UI, but this tool is still useful for components that haven't been migrated yet. **Parameters:** diff --git a/packages/ui/README.md b/packages/ui/README.md index 4c659cdaa..d3698c605 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,6 +1,8 @@ # @corates/ui -Shared UI component library for Corates built with [Zag.js](https://zagjs.com/) and [SolidJS](https://solidjs.com/). +Shared UI component library for Corates built with [Ark UI](https://ark-ui.com/) and [SolidJS](https://solidjs.com/). + +Most components have been migrated from Zag.js to Ark UI, which provides a more modern and maintainable API while maintaining backward compatibility. ## Installation @@ -26,7 +28,7 @@ import { Dialog, Select, Toast, toaster } from '@corates/ui'; ## Components -The following Zag.js components are available: +The following components are available (most migrated to Ark UI): | Component | Description | | ------------- | ---------------------------------------------------- | diff --git a/packages/ui/package.json b/packages/ui/package.json index fb8a68b5f..c6a8e0d19 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@corates/ui", "version": "0.0.1", "private": true, - "description": "Shared UI components for Corates built with Zag.js and SolidJS", + "description": "Shared UI components for Corates built with Ark UI and SolidJS", "type": "module", "main": "./src/index.js", "types": "./src/index.d.ts", @@ -20,33 +20,8 @@ "./zag/*": "./src/zag/*.jsx" }, "dependencies": { - "@zag-js/accordion": "^1.31.1", - "@zag-js/avatar": "^1.31.1", - "@zag-js/checkbox": "^1.31.1", - "@zag-js/clipboard": "^1.31.1", - "@zag-js/collapsible": "^1.31.1", - "@zag-js/combobox": "^1.31.1", - "@zag-js/dialog": "^1.31.1", - "@zag-js/editable": "^1.31.1", - "@zag-js/file-upload": "^1.31.1", - "@zag-js/floating-panel": "^1.31.1", - "@zag-js/menu": "^1.31.1", - "@zag-js/number-input": "^1.31.1", - "@zag-js/password-input": "^1.31.1", - "@zag-js/pin-input": "^1.31.1", - "@zag-js/popover": "^1.31.1", - "@zag-js/progress": "^1.31.1", - "@zag-js/qr-code": "^1.31.1", - "@zag-js/radio-group": "^1.31.1", - "@zag-js/select": "^1.31.1", + "@ark-ui/solid": "^5.30.0", "@zag-js/solid": "^1.31.1", - "@zag-js/splitter": "^1.31.1", - "@zag-js/switch": "^1.31.1", - "@zag-js/tabs": "^1.31.1", - "@zag-js/tags-input": "^1.31.1", - "@zag-js/toast": "^1.31.1", - "@zag-js/toggle-group": "^1.31.1", - "@zag-js/tooltip": "^1.31.1", "@zag-js/tour": "^1.31.1", "clsx": "^2.1.1", "tailwind-merge": "^3.4.0" diff --git a/packages/ui/src/index.d.ts b/packages/ui/src/index.d.ts index f2e76c1b0..f773bed07 100644 --- a/packages/ui/src/index.d.ts +++ b/packages/ui/src/index.d.ts @@ -179,22 +179,50 @@ export const CopyButton: Component; // Collapsible // ============================================================================ +export interface CollapsibleApi { + open: boolean; + visible: boolean; + setOpen: (open: boolean) => void; +} + export interface CollapsibleProps { /** Controlled open state */ open?: boolean; /** Initial open state (uncontrolled) */ defaultOpen?: boolean; /** Callback when open state changes */ - onOpenChange?: (_open: boolean) => void; + onOpenChange?: (_details: { open: boolean }) => void; /** Disable the collapsible */ disabled?: boolean; - /** Render function for trigger */ - trigger?: (_api: { open: boolean }) => JSX.Element; + /** Enable lazy mounting */ + lazyMount?: boolean; + /** Unmount content when closed */ + unmountOnExit?: boolean; + /** Height when collapsed */ + collapsedHeight?: string | number; + /** Width when collapsed */ + collapsedWidth?: string | number; + /** Callback when exit animation completes */ + onExitComplete?: () => void; + /** Custom IDs for root, content, trigger */ + ids?: { root?: string; content?: string; trigger?: string }; + /** Trigger element or render function receiving collapsible API */ + trigger?: JSX.Element | ((_api: CollapsibleApi) => JSX.Element); + /** Indicator element or render function receiving collapsible API */ + indicator?: JSX.Element | ((_api: CollapsibleApi) => JSX.Element); /** Collapsible content */ children?: JSX.Element; } -export const Collapsible: Component; +export const Collapsible: Component & { + Root: Component; + Trigger: Component; + Content: Component; + Indicator: Component; + RootProvider: Component; +}; + +export function useCollapsible(props?: any): Accessor; // ============================================================================ // Combobox diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js index 08ae6f6c6..8111de84e 100644 --- a/packages/ui/src/index.js +++ b/packages/ui/src/index.js @@ -1,6 +1,6 @@ // @corates/ui - Shared UI Components -// Re-export all Zag components +// Re-export all UI components (built with Ark UI and some remaining Zag.js components) export * from './zag/index.js'; // Re-export primitives diff --git a/packages/ui/src/zag/Accordion.jsx b/packages/ui/src/zag/Accordion.jsx index 96e5ede06..d42e6e73d 100644 --- a/packages/ui/src/zag/Accordion.jsx +++ b/packages/ui/src/zag/Accordion.jsx @@ -1,6 +1,9 @@ -import * as accordion from '@zag-js/accordion'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, For, splitProps } from 'solid-js'; +/** + * Accordion component using Ark UI + */ + +import { Accordion } from '@ark-ui/solid/accordion'; +import { For, splitProps } from 'solid-js'; /** * Accordion - Vertically stacked expandable sections @@ -16,35 +19,33 @@ import { createMemo, createUniqueId, For, splitProps } from 'solid-js'; * - orientation: 'horizontal' | 'vertical' - Orientation (default: 'vertical') * - class: string - Additional class for root element */ -export function Accordion(props) { +export default function AccordionComponent(props) { const [local, machineProps] = splitProps(props, ['items', 'class']); - const service = useMachine(accordion.machine, () => ({ - id: createUniqueId(), - collapsible: true, - ...machineProps, - })); - - const api = createMemo(() => accordion.connect(service, normalizeProps)); + const handleValueChange = details => { + if (machineProps.onValueChange) { + machineProps.onValueChange(details); + } + }; return ( -
{item => ( -
+

- + +

-
+
{item.content}
-
-
+ + )}
-
+ ); } -export default Accordion; +export { AccordionComponent as Accordion }; diff --git a/packages/ui/src/zag/Avatar.jsx b/packages/ui/src/zag/Avatar.jsx index 6de953aec..efb06f695 100644 --- a/packages/ui/src/zag/Avatar.jsx +++ b/packages/ui/src/zag/Avatar.jsx @@ -1,20 +1,26 @@ -import * as avatar from '@zag-js/avatar'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId } from 'solid-js'; +/** + * Avatar component using Ark UI + */ -export function Avatar(props) { +import { Avatar } from '@ark-ui/solid/avatar'; + +/** + * Avatar - User avatar with fallback support + * + * Props: + * - src: string - Image source URL + * - name: string - Name for generating initials fallback + * - alt: string - Alt text for image + * - onStatusChange: (details: StatusChangeDetails) => void - Callback when image loading status changes + * - fallbackClass: string - CSS classes for fallback element + * - class: string - Additional class for root element + */ +export default function AvatarComponent(props) { const src = () => props.src; const name = () => props.name; const alt = () => props.alt || name() || 'Avatar'; const onStatusChange = () => props.onStatusChange; - const service = useMachine(avatar.machine, () => ({ - id: createUniqueId(), - onStatusChange: onStatusChange(), - })); - - const api = createMemo(() => avatar.connect(service, normalizeProps)); - const getInitials = () => { const displayName = name(); if (!displayName) return ''; @@ -28,19 +34,16 @@ export function Avatar(props) { 'flex items-center justify-center w-full h-full bg-gray-200 text-gray-700 font-medium'; return ( -
- - {getInitials()} - - + {getInitials()} + -
+ ); } -export default Avatar; +export { AvatarComponent as Avatar }; diff --git a/packages/ui/src/zag/Checkbox.jsx b/packages/ui/src/zag/Checkbox.jsx index 24dc9cb9e..9870a2134 100644 --- a/packages/ui/src/zag/Checkbox.jsx +++ b/packages/ui/src/zag/Checkbox.jsx @@ -1,25 +1,28 @@ /** - * Checkbox component using Zag.js + * Checkbox component using Ark UI + * + * Supports both high-level convenience API and low-level composition API */ -import * as checkbox from '@zag-js/checkbox'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, mergeProps, Show } from 'solid-js'; +import { Checkbox as ArkCheckbox, useCheckbox } from '@ark-ui/solid/checkbox'; +import { mergeProps, splitProps, Show, createMemo } from 'solid-js'; import { BiRegularCheck, BiRegularMinus } from 'solid-icons/bi'; /** - * @param {Object} props - * @param {boolean} [props.checked] - Controlled checked state - * @param {boolean} [props.defaultChecked] - Default checked state (uncontrolled) - * @param {boolean} [props.indeterminate] - Whether checkbox is in indeterminate state - * @param {boolean} [props.disabled] - Whether checkbox is disabled - * @param {string} [props.name] - Name for form submission - * @param {string} [props.value] - Value for form submission - * @param {string} [props.label] - Label text - * @param {Function} [props.onChange] - Callback when checked state changes: (checked: boolean) => void - * @param {string} [props.class] - Additional CSS classes + * Checkbox - Full description + * + * Props: + * - checked: boolean - Controlled checked state + * - defaultChecked: boolean - Default checked state (uncontrolled) + * - indeterminate: boolean - Whether checkbox is in indeterminate state + * - disabled: boolean - Whether checkbox is disabled + * - name: string - Name for form submission + * - value: string - Value for form submission + * - label: string - Label text + * - onChange: Function - Callback when checked state changes: (checked: boolean) => void + * - class: string - Additional CSS classes */ -export function Checkbox(props) { +export default function CheckboxComponent(props) { const merged = mergeProps( { defaultChecked: false, @@ -27,59 +30,72 @@ export function Checkbox(props) { props, ); - const checked = () => merged.checked; - const indeterminate = () => merged.indeterminate; - const defaultChecked = () => merged.defaultChecked; - const disabled = () => merged.disabled; - const name = () => merged.name; - const value = () => merged.value; - const classValue = () => merged.class; - const label = () => merged.label; + const [local, machineProps] = splitProps(merged, ['label', 'class', 'indeterminate', 'onChange']); - const service = useMachine(checkbox.machine, () => ({ - id: createUniqueId(), - checked: indeterminate() ? 'indeterminate' : checked(), - defaultChecked: defaultChecked(), - disabled: disabled(), - name: name(), - value: value(), - onCheckedChange(details) { - merged.onChange?.(details.checked === true); - }, - })); + const label = () => local.label; + const classValue = () => local.class; + const indeterminate = () => local.indeterminate; + const onChange = () => local.onChange; + + const checked = () => machineProps.checked; + const defaultChecked = () => machineProps.defaultChecked; + const disabled = () => machineProps.disabled; + const name = () => machineProps.name; + const value = () => machineProps.value || 'on'; + + // Convert indeterminate to checked state + const checkedState = createMemo(() => { + if (indeterminate()) return 'indeterminate'; + if (checked() !== undefined) return checked() === true; + return undefined; + }); - const api = createMemo(() => checkbox.connect(service, normalizeProps)); + const defaultCheckedState = createMemo(() => { + if (indeterminate()) return 'indeterminate'; + return defaultChecked(); + }); + + const handleCheckedChange = details => { + if (onChange()) { + // When transitioning from indeterminate, treat it as checking + const newChecked = details.checked === true || details.checked === 'indeterminate'; + onChange()(newChecked); + } + if (machineProps.onCheckedChange) { + machineProps.onCheckedChange(details); + } + }; return ( - + ); } -export default Checkbox; +export { CheckboxComponent as Checkbox }; + +// Export hook for programmatic control +export { useCheckbox }; diff --git a/packages/ui/src/zag/Clipboard.jsx b/packages/ui/src/zag/Clipboard.jsx index bd3f290d4..2fa1493aa 100644 --- a/packages/ui/src/zag/Clipboard.jsx +++ b/packages/ui/src/zag/Clipboard.jsx @@ -1,6 +1,9 @@ -import * as clipboard from '@zag-js/clipboard'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show, splitProps } from 'solid-js'; +/** + * Clipboard components using Ark UI + */ + +import { Clipboard } from '@ark-ui/solid/clipboard'; +import { Show, splitProps, createSignal, createMemo } from 'solid-js'; import { FiCopy, FiCheck } from 'solid-icons/fi'; /** @@ -17,46 +20,68 @@ import { FiCopy, FiCheck } from 'solid-icons/fi'; * - children: (api: ClipboardApi) => JSX.Element - Render function for custom UI * - class: string - Additional class for root element */ -export function Clipboard(props) { +export default function ClipboardComponent(props) { const [local, machineProps] = splitProps(props, ['label', 'showInput', 'children', 'class']); + const [copied, setCopied] = createSignal(false); - const service = useMachine(clipboard.machine, () => ({ - id: createUniqueId(), - timeout: 3000, - ...machineProps, - })); + const handleStatusChange = details => { + setCopied(details.copied); + if (machineProps.onStatusChange) { + machineProps.onStatusChange({ copied: details.copied }); + } + }; + + const handleValueChange = details => { + if (machineProps.onValueChange) { + machineProps.onValueChange(details); + } + }; - const api = createMemo(() => clipboard.connect(service, normalizeProps)); + // Create API object for render prop compatibility + const api = createMemo(() => ({ + get copied() { + return copied(); + }, + copy: () => { + // Trigger copy via the Clipboard.Trigger + }, + })); const showInput = () => local.showInput !== false; return ( - -
+ + - + -
+ - + - -
-
+ }> + + + {copied() ? 'Copied!' : 'Copy'} + + +
); } @@ -86,14 +111,14 @@ export function CopyButton(props) { 'showLabel', 'class', ]); + const [copied, setCopied] = createSignal(false); - const service = useMachine(clipboard.machine, () => ({ - id: createUniqueId(), - timeout: 3000, - ...machineProps, - })); - - const api = createMemo(() => clipboard.connect(service, normalizeProps)); + const handleStatusChange = details => { + setCopied(details.copied); + if (machineProps.onStatusChange) { + machineProps.onStatusChange({ copied: details.copied }); + } + }; const showIcon = () => local.showIcon !== false; const showLabel = () => local.showLabel !== false; @@ -112,16 +137,15 @@ export function CopyButton(props) { }; const getVariantClass = () => { - const copied = api().copied; switch (local.variant) { case 'outline': - return copied ? + return copied() ? 'border border-green-500 text-green-700 hover:bg-green-50' : 'border border-gray-300 text-gray-700 hover:bg-gray-50'; case 'ghost': - return copied ? 'text-green-700 hover:bg-green-100' : 'text-gray-700 hover:bg-gray-100'; + return copied() ? 'text-green-700 hover:bg-green-100' : 'text-gray-700 hover:bg-gray-100'; default: - return copied ? + return copied() ? 'bg-green-600 text-white hover:bg-green-700' : 'bg-blue-600 text-white hover:bg-blue-700'; } @@ -139,22 +163,26 @@ export function CopyButton(props) { }; return ( -
- -
+ + ); } -export default Clipboard; +export { ClipboardComponent as Clipboard }; diff --git a/packages/ui/src/zag/Collapsible.jsx b/packages/ui/src/zag/Collapsible.jsx index 5b2772996..498703716 100644 --- a/packages/ui/src/zag/Collapsible.jsx +++ b/packages/ui/src/zag/Collapsible.jsx @@ -1,64 +1,166 @@ -import * as collapsible from '@zag-js/collapsible'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId } from 'solid-js'; +/** + * Collapsible component using Ark UI + * + * Supports both high-level convenience API and low-level composition API + */ + +import { Collapsible as ArkCollapsible, useCollapsible } from '@ark-ui/solid/collapsible'; +import { Show, splitProps, createMemo } from 'solid-js'; /** - * Zag.js Collapsible component + * High-level Collapsible component (convenience API) + * * @param {Object} props - * @param {boolean} props.open - Controlled open state - * @param {boolean} props.defaultOpen - Default open state (uncontrolled) - * @param {Function} props.onOpenChange - Callback when open state changes - * @param {boolean} props.disabled - Whether the collapsible is disabled - * @param {JSX.Element} props.trigger - The trigger element (receives api) - * @param {JSX.Element} props.children - The content to show/hide + * @param {boolean} [props.open] - Controlled open state + * @param {boolean} [props.defaultOpen] - Default open state (uncontrolled) + * @param {Function} [props.onOpenChange] - Callback when open state changes (receives { open: boolean }) + * @param {boolean} [props.disabled] - Whether the collapsible is disabled + * @param {boolean} [props.lazyMount] - Enable lazy mounting + * @param {boolean} [props.unmountOnExit] - Unmount content when closed + * @param {string|number} [props.collapsedHeight] - Height when collapsed + * @param {string|number} [props.collapsedWidth] - Width when collapsed + * @param {Function} [props.onExitComplete] - Callback when exit animation completes + * @param {Object} [props.ids] - Custom IDs for root, content, trigger + * @param {JSX.Element | Function} [props.trigger] - Trigger element or render function receiving collapsible API + * @param {JSX.Element | Function} [props.indicator] - Indicator element or render function receiving collapsible API + * @param {JSX.Element} [props.children] - Content to show/hide */ -export default function Collapsible(props) { - const service = useMachine(collapsible.machine, () => ({ - id: createUniqueId(), - get open() { - return props.open; - }, - defaultOpen: props.defaultOpen, - get disabled() { - return props.disabled; - }, - onOpenChange(details) { - props.onOpenChange?.(details.open); - }, - })); - - const api = createMemo(() => collapsible.connect(service, normalizeProps)); +// Internal component for when we need programmatic API (function triggers/indicators) +function CollapsibleWithApi(props) { + const arkProps = () => props.arkProps; + const collapsibleApi = useCollapsible(arkProps()); + + const renderTrigger = () => { + const triggerValue = props.trigger(); + if (!triggerValue) return null; + + if (typeof triggerValue === 'function') { + return triggerValue(collapsibleApi()); + } + + return triggerValue; + }; + + const renderIndicator = () => { + const indicatorValue = props.indicator(); + if (!indicatorValue) return null; + + if (typeof indicatorValue === 'function') { + return indicatorValue(collapsibleApi()); + } + + return indicatorValue; + }; + + // Handle click events on trigger to prevent toggling when clicking interactive elements + const handleTriggerClick = e => { + const target = e.target; + // Check if click is on an interactive element (but not the trigger itself) + const interactive = target.closest( + 'button:not([data-part="trigger"]), [role="button"]:not([data-part="trigger"]), [role="menuitem"], input, textarea, [data-editable], [data-scope="menu"], [data-scope="editable"], [data-selectable], a', + ); + if (interactive) { + e.stopPropagation(); + // Only prevent default for non-input elements to avoid breaking form interactions + if (!interactive.matches('input, textarea')) { + e.preventDefault(); + } + } + }; return ( -
- {props.trigger?.(api())} -
- {props.children} -
- -
+ + + + + {renderTrigger()} + + + + + {renderIndicator()} + + + {props.children()} + + ); } + +export default function CollapsibleComponent(props) { + // Split convenience props from Ark UI props + const [local, arkProps] = splitProps(props, ['trigger', 'indicator', 'children']); + + const trigger = () => local.trigger; + const indicator = () => local.indicator; + const children = () => local.children; + + // Check if we need the API for function triggers/indicators + const needsApi = createMemo(() => { + const triggerValue = trigger(); + const indicatorValue = indicator(); + return typeof triggerValue === 'function' || typeof indicatorValue === 'function'; + }); + + // Handle trigger - support both JSX and function (for non-API case) + const renderTrigger = () => { + const currentTrigger = trigger(); + if (!currentTrigger) return null; + return currentTrigger; + }; + + // Handle indicator - support both JSX and function (for non-API case) + const renderIndicator = () => { + const currentIndicator = indicator(); + if (!currentIndicator) return null; + return currentIndicator; + }; + + // Handle click events on trigger to prevent toggling when clicking interactive elements + const handleTriggerClick = e => { + const target = e.target; + // Check if click is on an interactive element (but not the trigger itself) + const interactive = target.closest( + 'button:not([data-part="trigger"]), [role="button"]:not([data-part="trigger"]), [role="menuitem"], input, textarea, [data-editable], [data-scope="menu"], [data-scope="editable"], [data-selectable], a', + ); + if (interactive) { + e.stopPropagation(); + // Only prevent default for non-input elements to avoid breaking form interactions + if (!interactive.matches('input, textarea')) { + e.preventDefault(); + } + } + }; + + // Render - use Show to conditionally render with or without API + return ( + + + + {renderTrigger()} + + + + + {renderIndicator()} + + + {children()} + + } + > + + + ); +} + +// Export hook for programmatic control +export { useCollapsible }; diff --git a/packages/ui/src/zag/Combobox.jsx b/packages/ui/src/zag/Combobox.jsx index a6f04b2d2..e4effd89a 100644 --- a/packages/ui/src/zag/Combobox.jsx +++ b/packages/ui/src/zag/Combobox.jsx @@ -1,7 +1,13 @@ -import * as combobox from '@zag-js/combobox'; +/** + * Combobox - Searchable select with autocomplete using Ark UI + * + * Supports both high-level convenience API and low-level composition API + */ + +import { Combobox as ArkCombobox, useCombobox, useListCollection } from '@ark-ui/solid/combobox'; +import { useFilter } from '@ark-ui/solid/locale'; import { Portal } from 'solid-js/web'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createSignal, createUniqueId, For, Show, splitProps } from 'solid-js'; +import { mergeProps, splitProps, createMemo, createEffect, Show, Index } from 'solid-js'; import { FiChevronDown, FiX, FiCheck } from 'solid-icons/fi'; import { Z_INDEX } from '../constants/zIndex.js'; @@ -28,8 +34,16 @@ import { Z_INDEX } from '../constants/zIndex.js'; * - class: string - Additional class for root element * - inputClass: string - Additional class for input element */ -export function Combobox(props) { - const [local, machineProps] = splitProps(props, [ +export default function ComboboxComponent(props) { + const merged = mergeProps( + { + openOnClick: true, + defaultValue: [], + }, + props, + ); + + const [local, machineProps] = splitProps(merged, [ 'items', 'label', 'placeholder', @@ -39,101 +53,105 @@ export function Combobox(props) { ]); const getItems = () => local.items || []; - const [options, setOptions] = createSignal(getItems()); - const collection = createMemo(() => - combobox.collection({ - items: options(), - itemToValue: item => item.value, - itemToString: item => item.label, - itemToDisabled: item => item.disabled, - }), - ); + // Use Ark UI's filter utility + const filterFn = useFilter({ sensitivity: 'base' }); - const service = useMachine(combobox.machine, () => ({ - id: createUniqueId(), - openOnClick: true, - ...machineProps, - get collection() { - return collection(); - }, - onOpenChange() { - setOptions(getItems()); - }, - onInputValueChange({ inputValue }) { - const items = getItems(); - const filtered = items.filter(item => - item.label.toLowerCase().includes(inputValue.toLowerCase()), - ); - setOptions(filtered.length > 0 ? filtered : items); - }, - })); + // Create collection with filtering + const { collection, filter, set } = useListCollection({ + initialItems: getItems(), + filter: filterFn().contains, + itemToString: item => item.label, + itemToValue: item => item.value, + itemToDisabled: item => item.disabled, + }); + + // Sync items when props.items changes + createEffect(() => { + const items = getItems(); + set(items); + }); + + const handleInputValueChange = details => { + filter(details.inputValue); + if (machineProps.onInputValueChange) { + machineProps.onInputValueChange(details); + } + }; - const api = createMemo(() => combobox.connect(service, normalizeProps)); + const handleValueChange = details => { + if (machineProps.onValueChange) { + machineProps.onValueChange(details); + } + }; - const content = () => ( -
- 0}> -
    { + const value = machineProps.value || machineProps.defaultValue || []; + return value.length > 0; + }); + + const renderContent = () => ( + + 0}> + - - {item => ( -
  • - {item.label} - - - -
  • - )} -
    -
+ + + {item => ( + + {item().label} + + + + + )} + + +
-
+ ); return ( -
+ - + -
- + - - + - -
- - - {content()} - + + + + {renderContent()} -
+ ); } -export default Combobox; +export { ComboboxComponent as Combobox }; + +// Export hook for programmatic control +export { useCombobox }; diff --git a/packages/ui/src/zag/Dialog.jsx b/packages/ui/src/zag/Dialog.jsx index de4235979..839808297 100644 --- a/packages/ui/src/zag/Dialog.jsx +++ b/packages/ui/src/zag/Dialog.jsx @@ -1,7 +1,10 @@ -import * as dialog from '@zag-js/dialog'; +/** + * Dialog components using Ark UI + */ + +import { Dialog as ArkDialog, useDialog } from '@ark-ui/solid/dialog'; import { Portal } from 'solid-js/web'; -import { useMachine, normalizeProps } from '@zag-js/solid'; -import { createMemo, createSignal, createUniqueId, Show } from 'solid-js'; +import { createSignal, Show } from 'solid-js'; import { FiAlertTriangle, FiX } from 'solid-icons/fi'; import { Z_INDEX } from '../constants/zIndex.js'; @@ -16,22 +19,18 @@ import { Z_INDEX } from '../constants/zIndex.js'; * - children: JSX.Element - Dialog content * - size: 'sm' | 'md' | 'lg' | 'xl' - Dialog width (default: 'md') */ -export function Dialog(props) { +export default function DialogComponent(props) { const open = () => props.open; const size = () => props.size; const title = () => props.title; const description = () => props.description; const children = () => props.children; - const service = useMachine(dialog.machine, { - id: createUniqueId(), - get open() { - return open(); - }, - onOpenChange: details => props.onOpenChange?.(details.open), - }); - - const api = createMemo(() => dialog.connect(service, normalizeProps)); + const handleOpenChange = details => { + if (props.onOpenChange) { + props.onOpenChange(details.open); + } + }; const getSizeClass = () => { switch (size()) { @@ -47,48 +46,41 @@ export function Dialog(props) { }; return ( - - - {/* Backdrop */} -
- {/* Positioner - scrollable container */} -
- {/* Content */} -
+ + + + - {/* Header */} -
-
-

- {title()} -

- -

- {description()} -

-
+ + {/* Header */} +
+
+ + {title()} + + + + {description()} + + +
+ + +
- -
- {/* Body */} -
{children()}
-
-
- - + {/* Body */} +
{children()}
+ + + + + ); } @@ -106,7 +98,7 @@ export function Dialog(props) { * - variant: 'danger' | 'warning' | 'info' - Visual variant (default: 'danger') * - loading: boolean - Whether confirm action is in progress */ -export function ConfirmDialog(props) { +export function ConfirmDialogComponent(props) { const open = () => props.open; const loading = () => props.loading; const variant = () => props.variant || 'danger'; @@ -115,22 +107,11 @@ export function ConfirmDialog(props) { const confirmText = () => props.confirmText; const cancelText = () => props.cancelText; - const service = useMachine(dialog.machine, { - id: createUniqueId(), - role: 'alertdialog', - get open() { - return open(); - }, - onOpenChange: details => props.onOpenChange?.(details.open), - get closeOnInteractOutside() { - return !loading(); - }, - get closeOnEscape() { - return !loading(); - }, - }); - - const api = createMemo(() => dialog.connect(service, normalizeProps)); + const handleOpenChange = details => { + if (props.onOpenChange) { + props.onOpenChange(details.open); + } + }; const getVariantStyles = () => { switch (variant()) { @@ -166,69 +147,68 @@ export function ConfirmDialog(props) { }; return ( - - - {/* Backdrop */} -
- {/* Positioner */} -
- {/* Content */} -
+ + + + -
-
- {/* Icon */} -
- -
- {/* Text content */} -
-

- {title()} -

-

- {description()} -

+ +
+
+ {/* Icon */} +
+ +
+ {/* Text content */} +
+ + {title()} + + + {description()} + +
+ {/* Close button */} + + +
- {/* Close button */} +
+ {/* Footer */} +
+
-
- {/* Footer */} -
- - -
-
-
-
-
+ + + + + ); } @@ -293,9 +273,9 @@ export function useConfirmDialog() { }; // Pre-bound component that uses the hook's state - function ConfirmDialogComponent() { + function ConfirmDialogHookComponent() { return ( - ({ open: isOpen(), onOpenChange: handleOpenChange, @@ -321,4 +301,7 @@ export function useConfirmDialog() { }; } -export default ConfirmDialog; +export { DialogComponent as Dialog, ConfirmDialogComponent as ConfirmDialog }; + +// Export hook for programmatic control +export { useDialog }; diff --git a/packages/ui/src/zag/Drawer.jsx b/packages/ui/src/zag/Drawer.jsx index ec89f5f20..8c426ebfe 100644 --- a/packages/ui/src/zag/Drawer.jsx +++ b/packages/ui/src/zag/Drawer.jsx @@ -1,7 +1,10 @@ -import * as dialog from '@zag-js/dialog'; +/** + * Drawer component using Ark UI Dialog + */ + +import { Dialog } from '@ark-ui/solid/dialog'; import { Portal } from 'solid-js/web'; -import { useMachine, normalizeProps } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show } from 'solid-js'; +import { Show } from 'solid-js'; import { FiX } from 'solid-icons/fi'; import { Z_INDEX } from '../constants/zIndex.js'; @@ -20,7 +23,7 @@ import { Z_INDEX } from '../constants/zIndex.js'; * - closeOnOutsideClick: boolean - Close when clicking backdrop (default: true) * - showBackdrop: boolean - Whether to show the dark overlay backdrop (default: true) */ -export function Drawer(props) { +export default function DrawerComponent(props) { const open = () => props.open; const side = () => props.side || 'right'; const size = () => props.size || 'md'; @@ -30,16 +33,11 @@ export function Drawer(props) { const showHeader = () => props.showHeader ?? true; const showBackdrop = () => props.showBackdrop ?? true; - const service = useMachine(dialog.machine, { - id: createUniqueId(), - get open() { - return open(); - }, - onOpenChange: details => props.onOpenChange?.(details.open), - closeOnInteractOutside: () => props.closeOnOutsideClick ?? true, - }); - - const api = createMemo(() => dialog.connect(service, normalizeProps)); + const handleOpenChange = details => { + if (props.onOpenChange) { + props.onOpenChange(details.open); + } + }; const getSizeClass = () => { switch (size()) { @@ -74,61 +72,53 @@ export function Drawer(props) { }; return ( - - - {/* Backdrop - optional */} - -
- - {/* Positioner - full height, aligned to side */} -
- {/* Content - slides in from side */} -
- {/* Header */} - -
-
- -

- {title()} -

-
- -

- {description()} -

-
+ + + + {/* Backdrop - optional */} + + + + {/* Positioner - full height, aligned to side */} + + {/* Content - slides in from side */} + + {/* Header */} + +
+
+ + + {title()} + + + + + {description()} + + +
+ + +
- -
- - {/* Body - scrollable */} -
{children()}
-
-
- - + + {/* Body - scrollable */} +
{children()}
+ + + + + ); } -export default Drawer; +export { DrawerComponent as Drawer }; diff --git a/packages/ui/src/zag/Editable.jsx b/packages/ui/src/zag/Editable.jsx index 18e0b4964..da86bab19 100644 --- a/packages/ui/src/zag/Editable.jsx +++ b/packages/ui/src/zag/Editable.jsx @@ -1,6 +1,9 @@ -import * as editable from '@zag-js/editable'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show, mergeProps, createEffect, on } from 'solid-js'; +/** + * Editable - An inline editable single-line text component using Ark UI + */ + +import { Editable } from '@ark-ui/solid/editable'; +import { Show, mergeProps } from 'solid-js'; import { FiCheck, FiX, FiEdit2 } from 'solid-icons/fi'; import { cn } from '../lib/cn.js'; @@ -37,7 +40,7 @@ const variants = { }; /** - * Editable - An inline editable single-line text component using Zag.js + * Editable - An inline editable single-line text component using Ark UI * * Best for: titles, names, labels - single line text that can be edited inline. * For multi-line text (descriptions, notes), use a manual textarea approach instead. @@ -65,7 +68,7 @@ const variants = { * - showEditIcon: boolean - Whether to show only an edit icon trigger (no save/cancel) (default: false) * - label: string - Optional label text */ -export default function Editable(props) { +export default function EditableComponent(props) { const merged = mergeProps( { placeholder: 'Click to edit...', @@ -100,110 +103,106 @@ export default function Editable(props) { const previewClass = () => merged.previewClass; const label = () => merged.label; - // Create stable ID outside the machine config to prevent focus loss on re-render - const id = createUniqueId(); + const handleValueChange = details => { + if (merged.onChange) { + merged.onChange(details.value); + } + }; - const service = useMachine(editable.machine, () => ({ - id, - // Always use defaultValue - Zag manages internal editing state - defaultValue: value() ?? defaultValue() ?? '', - placeholder: placeholder(), - disabled: disabled(), - readOnly: readOnly(), - autoResize: autoResize(), - activationMode: activationMode(), - submitMode: submitMode(), - selectOnFocus: selectOnFocus(), - maxLength: maxLength(), - onValueChange(details) { - merged.onChange?.(details.value); - }, - onValueCommit(details) { - merged.onSubmit?.(details.value); - }, - onValueRevert() { - merged.onCancel?.(); - }, - })); + const handleValueCommit = details => { + if (merged.onSubmit) { + merged.onSubmit(details.value); + } + }; - const api = createMemo(() => editable.connect(service, normalizeProps)); + const handleValueRevert = () => { + if (merged.onCancel) { + merged.onCancel(); + } + }; - // Sync external value changes ONLY when not editing - // This handles cases like the parent updating value after a successful save - createEffect( - on(value, newValue => { - if (newValue !== undefined && newValue !== api().value && !api().editing) { - api().setValue(newValue); - } - }), - ); + // Use value if provided, otherwise defaultValue, otherwise empty string + const editableValue = () => value() ?? defaultValue() ?? ''; return ( -
- - - - -
-
- - - {api().value || placeholder()} - -
+ + + {api => ( + <> + + + {label()} + + - {/* External controls for edit mode */} - -
- - - - } - > - - - -
-
+ + + {api().value || placeholder()} + + + + {/* External controls for edit mode */} + +
+ + + + } + > + + + + + + + +
+
- {/* Edit icon only (no save/cancel) */} - - - -
-
+ {/* Edit icon only (no save/cancel) */} + + + + + +
+ + )} + + ); } + +export { EditableComponent as Editable }; diff --git a/packages/ui/src/zag/FileUpload.jsx b/packages/ui/src/zag/FileUpload.jsx index bf76c4c41..532e99a18 100644 --- a/packages/ui/src/zag/FileUpload.jsx +++ b/packages/ui/src/zag/FileUpload.jsx @@ -1,92 +1,22 @@ -import * as fileUpload from '@zag-js/file-upload'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createUniqueId, createMemo, Index, Show, mergeProps } from 'solid-js'; -import { BiRegularCloudUpload, BiRegularTrash } from 'solid-icons/bi'; -import { CgFileDocument } from 'solid-icons/cg'; -import { useWindowDrag } from '../primitives/useWindowDrag.js'; - -/** - * Recursively read all files from a directory entry - * @param {FileSystemDirectoryEntry} dirEntry - * @returns {Promise} - */ -async function readDirectoryRecursively(dirEntry) { - const files = []; - const reader = dirEntry.createReader(); - - // readEntries may not return all entries at once, so we need to call it repeatedly - const readEntries = () => { - return new Promise((resolve, reject) => { - reader.readEntries(resolve, reject); - }); - }; - - let entries = await readEntries(); - while (entries.length > 0) { - for (const entry of entries) { - if (entry.isFile) { - const file = await new Promise((resolve, reject) => { - entry.file(resolve, reject); - }); - files.push(file); - } else if (entry.isDirectory) { - const subFiles = await readDirectoryRecursively(entry); - files.push(...subFiles); - } - } - entries = await readEntries(); - } - - return files; -} - /** - * Extract all files from a drop event, including files nested in directories - * @param {DataTransfer} dataTransfer - * @returns {Promise} + * FileUpload component using Ark UI */ -async function getFilesFromDrop(dataTransfer) { - const files = []; - const items = dataTransfer.items; - if (!items) { - // Fallback for browsers that don't support DataTransferItemList - return Array.from(dataTransfer.files); - } - - const entries = []; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - // webkitGetAsEntry is the standard way to get directory access - const entry = item.webkitGetAsEntry?.(); - if (entry) { - entries.push(entry); - } - } - - for (const entry of entries) { - if (entry.isFile) { - const file = await new Promise((resolve, reject) => { - entry.file(resolve, reject); - }); - files.push(file); - } else if (entry.isDirectory) { - const dirFiles = await readDirectoryRecursively(entry); - files.push(...dirFiles); - } - } - - return files; -} +import { FileUpload } from '@ark-ui/solid/file-upload'; +import { Show, mergeProps, For } from 'solid-js'; +import { BiRegularCloudUpload, BiRegularTrash } from 'solid-icons/bi'; +import { CgFileDocument } from 'solid-icons/cg'; +import { useWindowDrag } from '../primitives/useWindowDrag.js'; /** - * FileUpload - Reusable file upload component using Zag.js + * FileUpload - Reusable file upload component using Ark UI * * @param {Object} props * @param {string} [props.accept] - Accepted file types (e.g., 'application/pdf') * @param {boolean} [props.multiple] - Allow multiple file selection (default: false) * @param {Function} [props.onFilesChange] - Callback when files change: (files: File[]) => void * @param {Function} [props.onFileAccept] - Callback when files are accepted: (details: { files: File[] }) => void + * @param {Function} [props.onFileReject] - Callback when files are rejected: (details: { files: FileRejection[] }) => void * @param {string} [props.dropzoneText] - Custom text for the dropzone * @param {string} [props.buttonText] - Custom text for the trigger button * @param {string} [props.helpText] - Helper text below the dropzone text @@ -97,7 +27,7 @@ async function getFilesFromDrop(dataTransfer) { * @param {boolean} [props.compact] - Use compact/minimal styling * @param {boolean} [props.allowDirectories] - Allow dropping directories (default: true for multiple) */ -export function FileUpload(props) { +export default function FileUploadComponent(props) { const merged = mergeProps( { multiple: false, @@ -114,171 +44,107 @@ export function FileUpload(props) { // Allow directories by default when multiple files are allowed const allowDirs = () => merged.allowDirectories ?? merged.multiple; const showFileList = () => merged.showFileList; - const accept = () => merged.accept; + const acceptValue = () => merged.accept; const classValue = () => merged.class; const compact = () => merged.compact; const dropzoneClass = () => merged.dropzoneClass; const helpText = () => merged.helpText; - const service = useMachine(fileUpload.machine, { - id: createUniqueId(), - // eslint-disable-next-line solid/reactivity - accept: merged.accept ? { [merged.accept]: [] } : undefined, - // maxFiles > 1 enables the native multi-select file picker (Cmd+click) - // Setting to a high number allows unlimited files when multiple is true - // eslint-disable-next-line solid/reactivity - maxFiles: merged.multiple ? 100 : 1, - // eslint-disable-next-line solid/reactivity - disabled: merged.disabled, - onFileChange: details => { - merged.onFilesChange?.(details.acceptedFiles); - // When showFileList is false, parent manages files externally - // Clear internal state so deleted files don't reappear on next selection - if (!showFileList() && details.acceptedFiles.length > 0) { - // Use queueMicrotask to clear after the callback completes - queueMicrotask(() => { - const currentApi = fileUpload.connect(service, normalizeProps); - currentApi.clearFiles(); - }); - } - }, - onFileAccept: details => { - merged.onFileAccept?.(details); - }, - }); - - const api = createMemo(() => fileUpload.connect(service, normalizeProps)); - // Detect when files are being dragged over the window (even from external sources like Finder) const { isDraggingOverWindow } = useWindowDrag(); - // Combine internal isDragging with window-level drag detection - const isHighlighted = () => api().isDragging || isDraggingOverWindow(); - - // Custom drop handler to support directory drops - const handleDrop = async e => { - e.preventDefault(); - e.stopPropagation(); - - // Check if this is a directory drop by looking for webkitGetAsEntry - const items = e.dataTransfer?.items; - const hasDirectories = - items && - Array.from(items).some(item => { - const entry = item.webkitGetAsEntry?.(); - return entry?.isDirectory; - }); - - // If no directories and allowDirs is false, let normal handling occur - if (!hasDirectories && !allowDirs()) { - return; - } - - try { - const allFiles = await getFilesFromDrop(e.dataTransfer); - - if (allFiles.length === 0) return; - - // Filter files based on accept prop if specified - let filteredFiles = allFiles; - if (accept()) { - const acceptPatterns = accept() - .split(',') - .map(p => p.trim().toLowerCase()); - filteredFiles = allFiles.filter(file => { - const ext = '.' + file.name.split('.').pop().toLowerCase(); - const mimeType = (file.type || '').toLowerCase(); - return acceptPatterns.some(pattern => { - if (pattern.startsWith('.')) { - return ext === pattern; - } - if (pattern.includes('*')) { - const [type] = pattern.split('/'); - return mimeType.startsWith(type + '/'); - } - return mimeType === pattern || pattern === ext; - }); - }); - } + const handleFileChange = details => { + merged.onFilesChange?.(details.acceptedFiles); + }; - // Call the callback with filtered files - if (filteredFiles.length > 0) { - merged.onFilesChange?.(filteredFiles); - merged.onFileAccept?.({ files: filteredFiles }); - } - } catch (err) { - // Fall back to normal handling if directory reading fails - console.error('Error reading dropped items:', err); - } + const handleFileAccept = details => { + merged.onFileAccept?.(details); }; - // Format file size for display - const formatFileSize = bytes => { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + const handleFileReject = details => { + merged.onFileReject?.(details); }; return ( -
-
- -
- -
-

{merged.dropzoneText}

- -

{helpText()}

-
-
- or - -
-
- - 0}> -
    - - {file => ( -
  • + + {api => { + const isHighlighted = () => api().isDragging || isDraggingOverWindow(); + + return ( + <> + - -
    -

    +

    + +
    +

    {merged.dropzoneText}

    + +

    {helpText()}

    +
    +
    + or + - {file().name} -

    -

    {formatFileSize(file().size)}

    + {merged.buttonText} +
    - -
  • - )} -
    -
-
-
+ + + 0}> + + + {file => ( + + +
+ + {file.name} + + +
+ + + +
+ )} +
+
+
+ + ); + }} + + ); } -export default FileUpload; +export { FileUploadComponent as FileUpload }; diff --git a/packages/ui/src/zag/FloatingPanel.jsx b/packages/ui/src/zag/FloatingPanel.jsx index 0b6b7320a..065ca5fa9 100644 --- a/packages/ui/src/zag/FloatingPanel.jsx +++ b/packages/ui/src/zag/FloatingPanel.jsx @@ -1,6 +1,9 @@ -import * as floatingPanel from '@zag-js/floating-panel'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show } from 'solid-js'; +/** + * FloatingPanel component using Ark UI + */ + +import { FloatingPanel } from '@ark-ui/solid/floating-panel'; +import { Show } from 'solid-js'; import { Portal } from 'solid-js/web'; import { AiOutlineMinus, AiOutlineClose } from 'solid-icons/ai'; import { FiMaximize2 } from 'solid-icons/fi'; @@ -35,160 +38,145 @@ import { FaSolidWindowRestore } from 'solid-icons/fa'; * - closeOnEscape: boolean - Close panel on Escape key (default: true) * - persistRect: boolean - Persist size/position when closed (default: false) */ -export function FloatingPanel(props) { - const service = useMachine(floatingPanel.machine, () => ({ - id: createUniqueId(), - open: props.open, - defaultOpen: props.defaultOpen, - onOpenChange: details => props.onOpenChange?.(details), - defaultSize: props.defaultSize ?? { width: 320, height: 240 }, - size: props.size, - onSizeChange: details => props.onSizeChange?.(details), - defaultPosition: props.defaultPosition, - position: props.position, - onPositionChange: details => props.onPositionChange?.(details), - onStageChange: details => props.onStageChange?.(details), - resizable: props.resizable ?? true, - draggable: props.draggable ?? true, - minSize: props.minSize ?? { width: 200, height: 150 }, - maxSize: props.maxSize, - lockAspectRatio: props.lockAspectRatio ?? false, - closeOnEscape: props.closeOnEscape ?? true, - persistRect: props.persistRect ?? false, - })); - - const api = createMemo(() => floatingPanel.connect(service, normalizeProps)); +export default function FloatingPanelComponent(props) { const showControls = () => props.showControls ?? true; const showMinimize = () => showControls() && (props.showMinimize ?? true); const showMaximize = () => showControls() && (props.showMaximize ?? true); const showRestore = () => showControls() && (props.showRestore ?? true); const showClose = () => showControls() && (props.showClose ?? true); - // Helper to merge Zag's style with custom style - const contentProps = () => { - const cp = api().getContentProps(); - return { - ...cp, - onKeyDown: event => { - // Let Zag handle its own key bindings first. - // Note: Zag's default handler only runs when the content element itself - // is the event target, so Escape won't close when a child has focus. - cp.onKeyDown?.(event); - if (!(props.closeOnEscape ?? true)) return; - if (event.key !== 'Escape' && event.key !== 'Esc') return; - event.stopPropagation(); - event.preventDefault(); - api().setOpen(false); - }, - style: { ...cp.style, overflow: 'hidden' }, - }; + const handleOpenChange = details => { + if (props.onOpenChange) { + props.onOpenChange(details); + } }; - const resizeTrigger = axis => { - const rp = api().getResizeTriggerProps({ axis }); - const extraStyle = - axis.length === 1 ? - axis === 'n' || axis === 's' ? - { height: '8px' } - : { width: '8px' } - : { width: '12px', height: '12px' }; - return { ...rp, style: { ...rp.style, ...extraStyle } }; + const handleSizeChange = details => { + if (props.onSizeChange) { + props.onSizeChange(details); + } }; - const cx = (...parts) => parts.filter(Boolean).join(' '); + const handlePositionChange = details => { + if (props.onPositionChange) { + props.onPositionChange(details); + } + }; - const dragTriggerProps = () => { - const dp = api().getDragTriggerProps(); - return { ...dp, class: cx(dp.class, 'shrink-0') }; + const handleStageChange = details => { + if (props.onStageChange) { + props.onStageChange(details); + } }; - const bodyProps = () => { - const bp = api().getBodyProps(); - return { - ...bp, - class: cx(bp.class, 'flex-1 min-h-0 p-4 overflow-auto'), - }; + const getResizeTriggerStyle = axis => { + if (axis.length === 1) { + return axis === 'n' || axis === 's' ? { height: '8px' } : { width: '8px' }; + } + return { width: '12px', height: '12px' }; }; return ( - - -
-
- {/* Header with drag handle */} -
-
-

- {props.title} -

- -
- - - - - - - - - - - - -
-
-
-
+ + + {api => ( + + + + + {/* Header with drag handle */} + + + + {props.title} + + + + + + + + + + + + + + + + + + + + + + + + + + + - {/* Body */} -
{props.children}
+ {/* Body */} + + {props.children} + - {/* Resize handles - merge Zag's styles with our size overrides */} - -
-
-
-
-
-
-
-
- -
-
- - + {/* Resize handles */} + + + + + + + + + + + + + + + )} + + ); } -export default FloatingPanel; +export { FloatingPanelComponent as FloatingPanel }; diff --git a/packages/ui/src/zag/Menu.jsx b/packages/ui/src/zag/Menu.jsx index 7ef6fcabb..827c0c380 100644 --- a/packages/ui/src/zag/Menu.jsx +++ b/packages/ui/src/zag/Menu.jsx @@ -1,7 +1,10 @@ -import * as menu from '@zag-js/menu'; +/** + * Menu - Dropdown menu for actions using Ark UI + */ + +import { Menu } from '@ark-ui/solid/menu'; import { Portal } from 'solid-js/web'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show, For, splitProps } from 'solid-js'; +import { createSignal, Show, For, splitProps } from 'solid-js'; import { Z_INDEX } from '../constants/zIndex.js'; /** @@ -29,7 +32,7 @@ import { Z_INDEX } from '../constants/zIndex.js'; * - separator?: boolean - Render as separator instead of item * - groupLabel?: string - Render as group label */ -export function Menu(props) { +export default function MenuComponent(props) { const [local, machineProps] = splitProps(props, [ 'trigger', 'items', @@ -39,19 +42,25 @@ export function Menu(props) { 'class', ]); - const service = useMachine(menu.machine, () => ({ - id: createUniqueId(), - closeOnSelect: true, - positioning: { placement: local.placement || 'bottom-start' }, - ...machineProps, - })); + const handleSelect = details => { + if (machineProps.onSelect) { + machineProps.onSelect(details); + } + }; - const api = createMemo(() => menu.connect(service, normalizeProps)); + // Track open state for conditional rendering + const [isOpen, setIsOpen] = createSignal(false); - const content = () => ( -
-
    { + setIsOpen(details.open); + if (machineProps.onOpenChange) { + machineProps.onOpenChange(details); + } + }; + + const renderContent = () => ( + + @@ -62,46 +71,50 @@ export function Menu(props) { + {item.groupLabel} - + } > -
  • + } > -
  • {item.icon} - {item.label} -
  • + {item.label} +
    )}
    -
-
+ + ); return ( - <> - - - - {content()} + + + + {renderContent()} - + ); } -export default Menu; +export { MenuComponent as Menu }; diff --git a/packages/ui/src/zag/NumberInput.jsx b/packages/ui/src/zag/NumberInput.jsx index 2aa62dcba..403f1b29c 100644 --- a/packages/ui/src/zag/NumberInput.jsx +++ b/packages/ui/src/zag/NumberInput.jsx @@ -1,6 +1,9 @@ -import * as numberInput from '@zag-js/number-input'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show, splitProps, mergeProps } from 'solid-js'; +/** + * NumberInput - Numeric input with increment/decrement controls using Ark UI + */ + +import { NumberInput } from '@ark-ui/solid/number-input'; +import { Show, splitProps, mergeProps, createMemo } from 'solid-js'; import { FiMinus, FiPlus } from 'solid-icons/fi'; /** @@ -29,7 +32,7 @@ import { FiMinus, FiPlus } from 'solid-icons/fi'; * - class: string - Additional class for root element * - inputClass: string - Additional class for input element */ -export function NumberInput(props) { +export default function NumberInputComponent(props) { const [local, machineProps] = splitProps(props, [ 'label', 'placeholder', @@ -40,15 +43,10 @@ export function NumberInput(props) { ]); const context = mergeProps(machineProps, { - id: createUniqueId(), clampValueOnBlur: true, spinOnPress: true, }); - const service = useMachine(numberInput.machine, context); - - const api = createMemo(() => numberInput.connect(service, normalizeProps)); - const showControls = () => local.showControls !== false; const sizes = createMemo(() => { @@ -63,37 +61,51 @@ export function NumberInput(props) { }); return ( -
+ - +
- + - - +
-
+ ); } -export default NumberInput; +export { NumberInputComponent as NumberInput }; diff --git a/packages/ui/src/zag/PasswordInput.jsx b/packages/ui/src/zag/PasswordInput.jsx index d49d87e32..51d5237d5 100644 --- a/packages/ui/src/zag/PasswordInput.jsx +++ b/packages/ui/src/zag/PasswordInput.jsx @@ -1,56 +1,69 @@ -import * as passwordInput from '@zag-js/password-input'; -import { useMachine, normalizeProps } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show } from 'solid-js'; +/** + * PasswordInput component using Ark UI + */ + +import { PasswordInput } from '@ark-ui/solid/password-input'; +import { createSignal } from 'solid-js'; import { FiEyeOff, FiEye } from 'solid-icons/fi'; -export default function PasswordInput(props) { - const autoComplete = () => props.autoComplete; - const password = () => props.password; - const required = () => props.required; +/** + * PasswordInput - Password input with visibility toggle + * + * Props: + * - password: string - Controlled password value + * - onPasswordChange: (value: string) => void - Callback when password changes + * - autoComplete: 'current-password' | 'new-password' - Autocomplete attribute (default: 'new-password') + * - required: boolean - Whether input is required + * - inputClass: string - Additional class for input element + * - iconSize: number - Size of visibility icon (default: 20) + * - class: string - Additional class for root element + * - label: string - Input label (default: 'Password') + */ +export default function PasswordInputComponent(props) { + const autoComplete = () => props.autoComplete || 'new-password'; + const password = () => props.password || ''; + const required = () => props.required || false; const inputClass = () => props.inputClass; - const iconSize = () => props.iconSize; + const iconSize = () => props.iconSize || 20; const classValue = () => props.class; - const label = () => props.label; + const label = () => props.label || 'Password'; - const service = useMachine(passwordInput.machine, { - id: createUniqueId(), - autoComplete: () => autoComplete() || 'new-password', - value: () => password() || '', - required: () => required() || false, - }); + const [visible, setVisible] = createSignal(false); - const api = createMemo(() => passwordInput.connect(service, normalizeProps)); const computedInputClass = () => inputClass() || 'w-full pl-3 sm:pl-4 pr-3 sm:pr-4 py-2 text-xs sm:text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400 transition big-placeholder'; - const computedIconSize = () => iconSize() || 20; + + const handleInput = e => { + props.onPasswordChange?.(e.target.value); + }; return ( -
- -
- props.onPasswordChange?.(e.target.value)} + setVisible(details.visible)} + autoComplete={autoComplete()} + required={required()} + class={classValue()} + > + + {label()} + + + - -
-
+ + }> + + + + + ); } + +export { PasswordInputComponent as PasswordInput }; diff --git a/packages/ui/src/zag/PinInput.jsx b/packages/ui/src/zag/PinInput.jsx index 00c500297..3d2a00cfa 100644 --- a/packages/ui/src/zag/PinInput.jsx +++ b/packages/ui/src/zag/PinInput.jsx @@ -1,8 +1,22 @@ -import * as pinInput from '@zag-js/pin-input'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, mergeProps } from 'solid-js'; +/** + * PinInput component using Ark UI + */ -export default function PinInput(props) { +import { PinInput } from '@ark-ui/solid/pin-input'; +import { mergeProps, Index } from 'solid-js'; + +/** + * PinInput - OTP/PIN code input + * + * Props: + * - required: boolean - Whether input is required (default: true) + * - otp: boolean - Whether to use OTP mode (default: true) + * - autoComplete: string - Autocomplete attribute (default: 'one-time-code') + * - isError: boolean - Whether to show error state + * - onInput: (value: string) => void - Callback when value changes + * - onComplete: (value: string) => void - Callback when all inputs are filled + */ +export default function PinInputComponent(props) { const merged = mergeProps( { required: true, @@ -14,23 +28,19 @@ export default function PinInput(props) { const required = () => merged.required; const otp = () => merged.otp; - const autoComplete = () => merged.autoComplete; const isError = () => merged.isError; - const service = useMachine(pinInput.machine, () => ({ - id: createUniqueId(), - required: required(), - autoComplete: autoComplete(), - otp: otp(), - onValueChange(value) { - merged.onInput?.(value.valueAsString); - }, - onValueComplete(details) { - merged.onComplete?.(details.valueAsString); - }, - })); + const handleValueChange = details => { + if (merged.onInput) { + merged.onInput(details.valueAsString); + } + }; - const api = createMemo(() => pinInput.connect(service, normalizeProps)); + const handleValueComplete = details => { + if (merged.onComplete) { + merged.onComplete(details.valueAsString); + } + }; const inputClass = () => 'w-10 h-12 sm:w-14 sm:h-14 rounded-lg border-2 ' + @@ -41,14 +51,23 @@ export default function PinInput(props) { return (
-
- - - - - - -
+ + + + {(_, index) => ( + + )} + +
); } + +export { PinInputComponent as PinInput }; diff --git a/packages/ui/src/zag/Popover.jsx b/packages/ui/src/zag/Popover.jsx index c5e7d38d3..1bbb3941e 100644 --- a/packages/ui/src/zag/Popover.jsx +++ b/packages/ui/src/zag/Popover.jsx @@ -1,7 +1,11 @@ -import * as popover from '@zag-js/popover'; -import { Portal } from 'solid-js/web'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show, splitProps, mergeProps } from 'solid-js'; +/** + * Popover component using Ark UI + * + * Supports both high-level convenience API and low-level composition API + */ + +import { Popover as ArkPopover, usePopover } from '@ark-ui/solid/popover'; +import { mergeProps, splitProps, Show } from 'solid-js'; import { FiX } from 'solid-icons/fi'; import { Z_INDEX } from '../constants/zIndex.js'; @@ -25,8 +29,20 @@ import { Z_INDEX } from '../constants/zIndex.js'; * - inDialog: boolean - Set to true when used inside a Dialog * - class: string - Additional class for content */ -export function Popover(props) { - const [local, machineProps] = splitProps(props, [ +export default function PopoverComponent(props) { + const merged = mergeProps( + { + placement: 'bottom', + modal: false, + closeOnInteractOutside: true, + closeOnEscape: true, + showArrow: false, + showCloseButton: true, + }, + props, + ); + + const [local, machineProps] = splitProps(merged, [ 'trigger', 'children', 'title', @@ -37,72 +53,73 @@ export function Popover(props) { 'class', ]); - const context = mergeProps(machineProps, { - id: createUniqueId(), - closeOnInteractOutside: true, - closeOnEscape: true, - }); - - const service = useMachine(popover.machine, context); + const trigger = () => local.trigger; + const children = () => local.children; + const title = () => local.title; + const description = () => local.description; + const showArrow = () => local.showArrow; + const showCloseButton = () => local.showCloseButton !== false; + const inDialog = () => local.inDialog; + const classValue = () => local.class; - const api = createMemo(() => popover.connect(service, normalizeProps)); + const handleOpenChange = details => { + if (machineProps.onOpenChange) { + machineProps.onOpenChange(details); + } + }; - const showCloseButton = () => local.showCloseButton !== false; + const positioning = () => ({ + placement: machineProps.placement || 'bottom', + }); - const content = () => ( -
-
- -
-
-
- + return ( + + {trigger()} + + + + + + + - -
-
- -

- {local.title} -

-
- -

- {local.description} -

+ +
+
+ + + {title()} + + + + + {description()} + + +
+ + + +
- - - -
- + -
{local.children}
-
-
- ); - - return ( - <> - - - - {content()} - - - +
{children()}
+ + + ); } -export default Popover; +export { PopoverComponent as Popover }; + +// Export hook for programmatic control +export { usePopover }; diff --git a/packages/ui/src/zag/Progress.jsx b/packages/ui/src/zag/Progress.jsx index 079fe133a..02872281f 100644 --- a/packages/ui/src/zag/Progress.jsx +++ b/packages/ui/src/zag/Progress.jsx @@ -1,6 +1,9 @@ -import * as progress from '@zag-js/progress'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, Show, splitProps } from 'solid-js'; +/** + * Progress - Linear progress bar using Ark UI + */ + +import { Progress } from '@ark-ui/solid/progress'; +import { Show, splitProps, createMemo } from 'solid-js'; /** * Progress - Linear progress bar @@ -16,7 +19,7 @@ import { createMemo, createUniqueId, Show, splitProps } from 'solid-js'; * - indeterminate: boolean - Show indeterminate animation * - class: string - Additional class for root element */ -export function Progress(props) { +export default function ProgressComponent(props) { const [local, machineProps] = splitProps(props, [ 'label', 'showValue', @@ -26,15 +29,6 @@ export function Progress(props) { 'class', ]); - const service = useMachine(progress.machine, () => ({ - id: createUniqueId(), - min: 0, - max: 100, - ...machineProps, - })); - - const api = createMemo(() => progress.connect(service, normalizeProps)); - const getSizeClass = () => { switch (local.size) { case 'sm': @@ -59,34 +53,39 @@ export function Progress(props) { } }; + // For indeterminate, set value to undefined + const progressValue = createMemo(() => { + if (local.indeterminate) return undefined; + return machineProps.value; + }); + return ( -
+
- - {local.label} - + {local.label} - - {api().valueAsString} - +
-
-
+ -
-
+ +
); } -export default Progress; +export { ProgressComponent as Progress }; diff --git a/packages/ui/src/zag/QRCode.jsx b/packages/ui/src/zag/QRCode.jsx index 38b62a489..10c5a929c 100644 --- a/packages/ui/src/zag/QRCode.jsx +++ b/packages/ui/src/zag/QRCode.jsx @@ -1,9 +1,11 @@ -import { createMemo, createUniqueId, createEffect } from 'solid-js'; -import * as qrCode from '@zag-js/qr-code'; -import { useMachine, normalizeProps } from '@zag-js/solid'; +/** + * QR Code component using Ark UI + */ + +import { QrCode } from '@ark-ui/solid/qr-code'; /** - * QR Code component using Zag.js + * QR Code component * Renders an SVG-based QR code with customizable error correction and styling. * * @param {Object} props @@ -13,35 +15,26 @@ import { useMachine, normalizeProps } from '@zag-js/solid'; * @param {string} [props.alt='QR Code'] - Alt text for accessibility * @param {'L'|'M'|'Q'|'H'} [props.ecc='M'] - Error correction level (L=7%, M=15%, Q=25%, H=30%) */ -export default function QRCode(props) { +export default function QRCodeComponent(props) { const data = () => props.data; const ecc = () => props.ecc; const size = () => props.size; const classValue = () => props.class; const alt = () => props.alt; - const service = useMachine(qrCode.machine, { - id: createUniqueId(), - value: data(), - encoding: { - ecc: ecc() || 'M', - }, - }); - - const api = createMemo(() => qrCode.connect(service, normalizeProps)); - - createEffect(() => { - const currentData = data(); - if (currentData) { - api().setValue(currentData); - } - }); + // Map data to value for Ark UI compatibility + const value = () => data(); + const pixelSize = () => size() || 200; const containerSize = () => size() || 200; return ( - + + + + ); } diff --git a/packages/ui/src/zag/RadioGroup.jsx b/packages/ui/src/zag/RadioGroup.jsx index 12f45c6d3..d1046ec53 100644 --- a/packages/ui/src/zag/RadioGroup.jsx +++ b/packages/ui/src/zag/RadioGroup.jsx @@ -1,6 +1,9 @@ -import * as radio from '@zag-js/radio-group'; -import { normalizeProps, useMachine } from '@zag-js/solid'; -import { createMemo, createUniqueId, For, splitProps } from 'solid-js'; +/** + * RadioGroup - Radio button group for single selection using Ark UI + */ + +import { RadioGroup } from '@ark-ui/solid/radio-group'; +import { For, splitProps, createMemo } from 'solid-js'; /** * RadioGroup - Radio button group for single selection @@ -16,60 +19,59 @@ import { createMemo, createUniqueId, For, splitProps } from 'solid-js'; * - orientation: 'horizontal' | 'vertical' - Layout orientation (default: 'vertical') * - class: string - Additional class for root element */ -export function RadioGroup(props) { +export default function RadioGroupComponent(props) { const [local, machineProps] = splitProps(props, ['items', 'label', 'class']); - const service = useMachine(radio.machine, () => ({ - id: createUniqueId(), - orientation: 'vertical', - ...machineProps, - })); - - const api = createMemo(() => radio.connect(service, normalizeProps)); + const orientation = () => machineProps.orientation || 'vertical'; + const isVertical = createMemo(() => orientation() === 'vertical'); - const isVertical = () => api().orientation === 'vertical'; + const handleValueChange = details => { + if (machineProps.onValueChange) { + machineProps.onValueChange(details); + } + }; return ( -
- +
{item => ( -