diff --git a/.claude/agents/react-specialist.md b/.claude/agents/react-specialist.md new file mode 100644 index 0000000..fd8f559 --- /dev/null +++ b/.claude/agents/react-specialist.md @@ -0,0 +1,286 @@ +--- +name: react-specialist +description: Expert React specialist mastering React 18+ with modern patterns and ecosystem. Specializes in performance optimization, advanced hooks, server components, and production-ready architectures with focus on creating scalable, maintainable applications. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +You are a senior React specialist with expertise in React 18+ and the modern React ecosystem. Your focus spans advanced patterns, performance optimization, state management, and production architectures with emphasis on creating scalable applications that deliver exceptional user experiences. + + +When invoked: +1. Query context manager for React project requirements and architecture +2. Review component structure, state management, and performance needs +3. Analyze optimization opportunities, patterns, and best practices +4. Implement modern React solutions with performance and maintainability focus + +React specialist checklist: +- React 18+ features utilized effectively +- TypeScript strict mode enabled properly +- Component reusability > 80% achieved +- Performance score > 95 maintained +- Test coverage > 90% implemented +- Bundle size optimized thoroughly +- Accessibility compliant consistently +- Best practices followed completely + +Advanced React patterns: +- Compound components +- Render props pattern +- Higher-order components +- Custom hooks design +- Context optimization +- Ref forwarding +- Portals usage +- Lazy loading + +State management: +- Redux Toolkit +- Zustand setup +- Jotai atoms +- Recoil patterns +- Context API +- Local state +- Server state +- URL state + +Performance optimization: +- React.memo usage +- useMemo patterns +- useCallback optimization +- Code splitting +- Bundle analysis +- Virtual scrolling +- Concurrent features +- Selective hydration + +Server-side rendering: +- Next.js integration +- Remix patterns +- Server components +- Streaming SSR +- Progressive enhancement +- SEO optimization +- Data fetching +- Hydration strategies + +Testing strategies: +- React Testing Library +- Jest configuration +- Cypress E2E +- Component testing +- Hook testing +- Integration tests +- Performance testing +- Accessibility testing + +React ecosystem: +- React Query/TanStack +- React Hook Form +- Framer Motion +- React Spring +- Material-UI +- Ant Design +- Tailwind CSS +- Styled Components + +Component patterns: +- Atomic design +- Container/presentational +- Controlled components +- Error boundaries +- Suspense boundaries +- Portal patterns +- Fragment usage +- Children patterns + +Hooks mastery: +- useState patterns +- useEffect optimization +- useContext best practices +- useReducer complex state +- useMemo calculations +- useCallback functions +- useRef DOM/values +- Custom hooks library + +Concurrent features: +- useTransition +- useDeferredValue +- Suspense for data +- Error boundaries +- Streaming HTML +- Progressive hydration +- Selective hydration +- Priority scheduling + +Migration strategies: +- Class to function components +- Legacy lifecycle methods +- State management migration +- Testing framework updates +- Build tool migration +- TypeScript adoption +- Performance upgrades +- Gradual modernization + +## Communication Protocol + +### React Context Assessment + +Initialize React development by understanding project requirements. + +React context query: +```json +{ + "requesting_agent": "react-specialist", + "request_type": "get_react_context", + "payload": { + "query": "React context needed: project type, performance requirements, state management approach, testing strategy, and deployment target." + } +} +``` + +## Development Workflow + +Execute React development through systematic phases: + +### 1. Architecture Planning + +Design scalable React architecture. + +Planning priorities: +- Component structure +- State management +- Routing strategy +- Performance goals +- Testing approach +- Build configuration +- Deployment pipeline +- Team conventions + +Architecture design: +- Define structure +- Plan components +- Design state flow +- Set performance targets +- Create testing strategy +- Configure build tools +- Setup CI/CD +- Document patterns + +### 2. Implementation Phase + +Build high-performance React applications. + +Implementation approach: +- Create components +- Implement state +- Add routing +- Optimize performance +- Write tests +- Handle errors +- Add accessibility +- Deploy application + +React patterns: +- Component composition +- State management +- Effect management +- Performance optimization +- Error handling +- Code splitting +- Progressive enhancement +- Testing coverage + +Progress tracking: +```json +{ + "agent": "react-specialist", + "status": "implementing", + "progress": { + "components_created": 47, + "test_coverage": "92%", + "performance_score": 98, + "bundle_size": "142KB" + } +} +``` + +### 3. React Excellence + +Deliver exceptional React applications. + +Excellence checklist: +- Performance optimized +- Tests comprehensive +- Accessibility complete +- Bundle minimized +- SEO optimized +- Errors handled +- Documentation clear +- Deployment smooth + +Delivery notification: +"React application completed. Created 47 components with 92% test coverage. Achieved 98 performance score with 142KB bundle size. Implemented advanced patterns including server components, concurrent features, and optimized state management." + +Performance excellence: +- Load time < 2s +- Time to interactive < 3s +- First contentful paint < 1s +- Core Web Vitals passed +- Bundle size minimal +- Code splitting effective +- Caching optimized +- CDN configured + +Testing excellence: +- Unit tests complete +- Integration tests thorough +- E2E tests reliable +- Visual regression tests +- Performance tests +- Accessibility tests +- Snapshot tests +- Coverage reports + +Architecture excellence: +- Components reusable +- State predictable +- Side effects managed +- Errors handled gracefully +- Performance monitored +- Security implemented +- Deployment automated +- Monitoring active + +Modern features: +- Server components +- Streaming SSR +- React transitions +- Concurrent rendering +- Automatic batching +- Suspense for data +- Error boundaries +- Hydration optimization + +Best practices: +- TypeScript strict +- ESLint configured +- Prettier formatting +- Husky pre-commit +- Conventional commits +- Semantic versioning +- Documentation complete +- Code reviews thorough + +Integration with other agents: +- Collaborate with frontend-developer on UI patterns +- Support fullstack-developer on React integration +- Work with typescript-pro on type safety +- Guide javascript-pro on modern JavaScript +- Help performance-engineer on optimization +- Assist qa-expert on testing strategies +- Partner with accessibility-specialist on a11y +- Coordinate with devops-engineer on deployment + +Always prioritize performance, maintainability, and user experience while building React applications that scale effectively and deliver exceptional results. \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1294d89..cdf09f8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,9 @@ "Bash(npm test:*)", "Bash(npm run:*)", "Bash(gh issue create:*)", - "Bash(gh label create:*)" + "Bash(gh label create:*)", + "Bash(gh issue view:*)", + "Bash(gh pr view:*)" ], "deny": [], "ask": [] diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..bd98b4f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/package-lock.json b/package-lock.json index dfd58c0..e3003ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,9 @@ "@electron/typescript-definitions": "^8.15.6", "@eslint/js": "^9.39.1", "@reforged/maker-appimage": "^5.1.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/electron-squirrel-startup": "^1.0.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", @@ -62,6 +65,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^27.4.0", "prettier": "3.7.4", "shadcn": "^3.6.2", "typescript": "~5.9.3", @@ -71,6 +75,13 @@ "vitest": "^4.0.15" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "license": "MIT" + }, "node_modules/@antfu/ni": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz", @@ -93,6 +104,61 @@ "nup": "bin/nup.mjs" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -598,6 +664,141 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.51.2", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.2.tgz", @@ -2400,6 +2601,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz", + "integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -5057,6 +5276,68 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -5113,6 +5394,13 @@ "@types/node": "*" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6365,6 +6653,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -6539,6 +6837,16 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -7632,6 +7940,20 @@ "node": ">=12.10" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -7645,6 +7967,21 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", + "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -7674,6 +8011,57 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/debounce-fn": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", @@ -7717,6 +8105,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -7925,6 +8320,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -7982,6 +8387,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-walk": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", @@ -8836,6 +9248,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -10701,6 +11126,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -11281,6 +11719,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -11521,6 +11966,134 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12326,6 +12899,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/macos-alias": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/macos-alias/-/macos-alias-0.2.12.tgz", @@ -12425,6 +13008,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -13607,6 +14197,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -14073,6 +14676,34 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-ms": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz", @@ -14362,6 +14993,13 @@ "react": "^19.2.3" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-konva": { "version": "19.2.1", "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.1.tgz", @@ -15125,6 +15763,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -16641,6 +17292,13 @@ "camelcase": "^3.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -17813,6 +18471,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -17944,6 +18615,16 @@ "node": ">=4.0" } }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -18116,6 +18797,16 @@ "xtend": "^4.0.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml-parse-from-string": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", @@ -18157,6 +18848,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 8ab9aaf..e97c6ac 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,9 @@ "@electron/typescript-definitions": "^8.15.6", "@eslint/js": "^9.39.1", "@reforged/maker-appimage": "^5.1.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/electron-squirrel-startup": "^1.0.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", @@ -75,6 +78,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^27.4.0", "prettier": "3.7.4", "shadcn": "^3.6.2", "typescript": "~5.9.3", diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index c100d06..51af7f1 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,8 +1,7 @@ -import { useState, useEffect } from "react"; import { useShallow } from "zustand/react/shallow"; import { useMachineStore } from "../stores/useMachineStore"; import { useUIStore } from "../stores/useUIStore"; -import { usePrevious } from "../hooks/usePrevious"; +import { useErrorPopoverState } from "@/hooks"; import { WorkflowStepper } from "./WorkflowStepper"; import { ErrorPopoverContent } from "./ErrorPopover"; import { @@ -61,17 +60,16 @@ export function AppHeader() { })), ); - // State management for error popover auto-open/close - const [errorPopoverOpen, setErrorPopoverOpen] = useState(false); - const [dismissedErrorCode, setDismissedErrorCode] = useState( - null, - ); - const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false); - - // Track previous values for comparison - const prevMachineError = usePrevious(machineError); - const prevErrorMessage = usePrevious(machineErrorMessage); - const prevPyodideError = usePrevious(pyodideError); + // Error popover state management + const { + isOpen: errorPopoverOpen, + handleOpenChange: handlePopoverOpenChange, + } = useErrorPopoverState({ + machineError, + machineErrorMessage, + pyodideError, + hasError, + }); // Get state visual info for header status badge const stateVisual = getStateVisualInfo(machineStatus); @@ -90,67 +88,6 @@ export function AppHeader() { ? getStatusIndicatorState(machineStatus) : "idle"; - // Auto-open/close error popover based on error state changes - /* eslint-disable react-hooks/set-state-in-effect */ - useEffect(() => { - // Check if there's any error now - const hasAnyError = - machineErrorMessage || pyodideError || hasError(machineError); - // Check if there was any error before - const hadAnyError = - prevErrorMessage || prevPyodideError || hasError(prevMachineError); - - // Auto-open popover when new error appears (but not if user manually dismissed) - const isNewMachineError = - hasError(machineError) && - machineError !== prevMachineError && - machineError !== dismissedErrorCode; - const isNewErrorMessage = - machineErrorMessage && machineErrorMessage !== prevErrorMessage; - const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError; - - if ( - !wasManuallyDismissed && - (isNewMachineError || isNewErrorMessage || isNewPyodideError) - ) { - setErrorPopoverOpen(true); - } - - // Auto-close popover when all errors are cleared - if (!hasAnyError && hadAnyError) { - setErrorPopoverOpen(false); - setDismissedErrorCode(null); // Reset dismissed tracking - setWasManuallyDismissed(false); // Reset manual dismissal flag - } - }, [ - machineError, - machineErrorMessage, - pyodideError, - dismissedErrorCode, - wasManuallyDismissed, - prevMachineError, - prevErrorMessage, - prevPyodideError, - ]); - /* eslint-enable react-hooks/set-state-in-effect */ - - // Handle manual popover dismiss - const handlePopoverOpenChange = (open: boolean) => { - setErrorPopoverOpen(open); - - // If user manually closes it while any error is present, remember this to prevent reopening - if ( - !open && - (hasError(machineError) || machineErrorMessage || pyodideError) - ) { - setWasManuallyDismissed(true); - // Also track the specific machine error code if present - if (hasError(machineError)) { - setDismissedErrorCode(machineError); - } - } - }; - return (
diff --git a/src/components/BluetoothDevicePicker.tsx b/src/components/BluetoothDevicePicker.tsx index c89c5e8..6b8c3d1 100644 --- a/src/components/BluetoothDevicePicker.tsx +++ b/src/components/BluetoothDevicePicker.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, useCallback } from "react"; -import type { BluetoothDevice } from "../types/electron"; +import { useState, useCallback, useEffect } from "react"; +import { useBluetoothDeviceListener } from "@/hooks"; import { Dialog, DialogContent, @@ -11,42 +11,41 @@ import { import { Button } from "@/components/ui/button"; export function BluetoothDevicePicker() { - const [devices, setDevices] = useState([]); const [isOpen, setIsOpen] = useState(false); - const [isScanning, setIsScanning] = useState(false); + // Use Bluetooth device listener hook + const { devices, isScanning } = useBluetoothDeviceListener((deviceList) => { + console.log("[BluetoothPicker] Received device list:", deviceList); + // Open the picker when devices are received + // Use functional setState to avoid stale closure + setIsOpen((prevIsOpen) => { + if (!prevIsOpen && deviceList.length >= 0) { + return true; + } + return prevIsOpen; + }); + }); + + // Close modal and reset when scan completes with no selection useEffect(() => { - // Only set up listener in Electron - if (window.electronAPI?.onBluetoothDeviceList) { - window.electronAPI.onBluetoothDeviceList((deviceList) => { - console.log("[BluetoothPicker] Received device list:", deviceList); - setDevices(deviceList); - // Open the picker when scan starts (even if empty at first) - if (!isOpen) { - setIsOpen(true); - setIsScanning(true); - } - // Stop showing scanning state once we have devices - if (deviceList.length > 0) { - setIsScanning(false); - } - }); + if (isOpen && !isScanning && devices.length === 0) { + const timer = setTimeout(() => { + setIsOpen(false); + }, 2000); + return () => clearTimeout(timer); } - }, [isOpen]); + }, [isOpen, isScanning, devices]); const handleSelectDevice = useCallback((deviceId: string) => { console.log("[BluetoothPicker] User selected device:", deviceId); window.electronAPI?.selectBluetoothDevice(deviceId); setIsOpen(false); - setDevices([]); }, []); const handleCancel = useCallback(() => { console.log("[BluetoothPicker] User cancelled device selection"); window.electronAPI?.selectBluetoothDevice(""); setIsOpen(false); - setDevices([]); - setIsScanning(false); }, []); return ( diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index 394df09..f89990d 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -10,9 +10,11 @@ import { canUploadPattern, getMachineStateCategory, } from "../utils/machineStateHelpers"; -import { useFileUpload } from "../hooks/useFileUpload"; -import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload"; -import { usePatternValidation } from "../hooks/usePatternValidation"; +import { + useFileUpload, + usePatternRotationUpload, + usePatternValidation, +} from "@/hooks"; import { PatternInfoSkeleton } from "./SkeletonLoader"; import { PatternInfo } from "./PatternInfo"; import { diff --git a/src/components/PatternCanvas/PatternCanvas.tsx b/src/components/PatternCanvas/PatternCanvas.tsx index e7cda97..f791876 100644 --- a/src/components/PatternCanvas/PatternCanvas.tsx +++ b/src/components/PatternCanvas/PatternCanvas.tsx @@ -21,8 +21,7 @@ import { ThreadLegend } from "./ThreadLegend"; import { PatternPositionIndicator } from "./PatternPositionIndicator"; import { ZoomControls } from "./ZoomControls"; import { PatternLayer } from "./PatternLayer"; -import { useCanvasViewport } from "../../hooks/useCanvasViewport"; -import { usePatternTransform } from "../../hooks/usePatternTransform"; +import { useCanvasViewport, usePatternTransform } from "@/hooks"; export function PatternCanvas() { // Machine store diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx index 26fea1f..66f6ef3 100644 --- a/src/components/ProgressMonitor.tsx +++ b/src/components/ProgressMonitor.tsx @@ -1,4 +1,5 @@ -import { useRef, useEffect, useMemo } from "react"; +import { useMemo } from "react"; +import { useAutoScroll } from "@/hooks"; import { useShallow } from "zustand/react/shallow"; import { useMachineStore } from "../stores/useMachineStore"; import { usePatternStore } from "../stores/usePatternStore"; @@ -54,7 +55,6 @@ export function ProgressMonitor() { const pesData = usePatternStore((state) => state.pesData); const uploadedPesData = usePatternStore((state) => state.uploadedPesData); const displayPattern = uploadedPesData || pesData; - const currentBlockRef = useRef(null); // State indicators const isMaskTraceComplete = @@ -127,14 +127,7 @@ export function ProgressMonitor() { }, [colorBlocks, currentStitch]); // Auto-scroll to current block - useEffect(() => { - if (currentBlockRef.current) { - currentBlockRef.current.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }); - } - }, [currentBlockIndex]); + const currentBlockRef = useAutoScroll(currentBlockIndex); return ( diff --git a/src/components/WorkflowStepper.tsx b/src/components/WorkflowStepper.tsx index 7f2b01f..40c12fe 100644 --- a/src/components/WorkflowStepper.tsx +++ b/src/components/WorkflowStepper.tsx @@ -1,4 +1,5 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef } from "react"; +import { useClickOutside } from "@/hooks"; import { useShallow } from "zustand/react/shallow"; import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore"; import { usePatternStore } from "../stores/usePatternStore"; @@ -269,29 +270,11 @@ export function WorkflowStepper() { const popoverRef = useRef(null); const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); - // Close popover when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - popoverRef.current && - !popoverRef.current.contains(event.target as Node) - ) { - // Check if click was on a step circle - const clickedStep = Object.values(stepRefs.current).find((ref) => - ref?.contains(event.target as Node), - ); - if (!clickedStep) { - setShowPopover(false); - } - } - }; - - if (showPopover) { - document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); - } - }, [showPopover]); + // Close popover when clicking outside (exclude step circles) + useClickOutside(popoverRef, () => setShowPopover(false), { + enabled: showPopover, + excludeRefs: [stepRefs], + }); const handleStepClick = (stepId: number) => { // Only allow clicking on current step or earlier completed steps diff --git a/src/hooks/domain/index.ts b/src/hooks/domain/index.ts new file mode 100644 index 0000000..200ebd1 --- /dev/null +++ b/src/hooks/domain/index.ts @@ -0,0 +1,12 @@ +export { usePatternValidation } from "./usePatternValidation"; +export { usePatternRotationUpload } from "./usePatternRotationUpload"; +export { useMachinePolling } from "./useMachinePolling"; +export { useErrorPopoverState } from "./useErrorPopoverState"; +export type { + UseMachinePollingOptions, + UseMachinePollingReturn, +} from "./useMachinePolling"; +export type { + UseErrorPopoverStateOptions, + UseErrorPopoverStateReturn, +} from "./useErrorPopoverState"; diff --git a/src/hooks/domain/useErrorPopoverState.test.ts b/src/hooks/domain/useErrorPopoverState.test.ts new file mode 100644 index 0000000..d7d497a --- /dev/null +++ b/src/hooks/domain/useErrorPopoverState.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { useErrorPopoverState } from "./useErrorPopoverState"; + +describe("useErrorPopoverState", () => { + const hasError = (error: number | undefined) => + error !== undefined && error !== 0; + + it("should start with popover closed", () => { + const { result } = renderHook(() => + useErrorPopoverState({ + machineError: undefined, + machineErrorMessage: null, + pyodideError: null, + hasError, + }), + ); + + expect(result.current.isOpen).toBe(false); + expect(result.current.wasManuallyDismissed).toBe(false); + expect(result.current.dismissedErrorCode).toBeNull(); + }); + + it("should auto-open when machine error appears", () => { + const { result, rerender } = renderHook( + ({ machineError }) => + useErrorPopoverState({ + machineError, + machineErrorMessage: null, + pyodideError: null, + hasError, + }), + { initialProps: { machineError: undefined as number | undefined } }, + ); + + expect(result.current.isOpen).toBe(false); + + // Error appears + rerender({ machineError: 1 as number | undefined }); + expect(result.current.isOpen).toBe(true); + }); + + it("should auto-open when machine error message appears", () => { + const { result, rerender } = renderHook( + ({ machineErrorMessage }) => + useErrorPopoverState({ + machineError: undefined, + machineErrorMessage, + pyodideError: null, + hasError, + }), + { initialProps: { machineErrorMessage: null as string | null } }, + ); + + expect(result.current.isOpen).toBe(false); + + rerender({ machineErrorMessage: "Error occurred" as string | null }); + expect(result.current.isOpen).toBe(true); + }); + + it("should auto-open when pyodide error appears", () => { + const { result, rerender } = renderHook( + ({ pyodideError }) => + useErrorPopoverState({ + machineError: undefined, + machineErrorMessage: null, + pyodideError, + hasError, + }), + { initialProps: { pyodideError: null as string | null } }, + ); + + expect(result.current.isOpen).toBe(false); + + rerender({ pyodideError: "Pyodide error" as string | null }); + expect(result.current.isOpen).toBe(true); + }); + + it("should auto-close when all errors are cleared", () => { + const { result, rerender } = renderHook( + ({ machineError }) => + useErrorPopoverState({ + machineError, + machineErrorMessage: null, + pyodideError: null, + hasError, + }), + { initialProps: { machineError: 1 } }, + ); + + expect(result.current.isOpen).toBe(true); + + // Clear error + rerender({ machineError: 0 }); + expect(result.current.isOpen).toBe(false); + expect(result.current.wasManuallyDismissed).toBe(false); + expect(result.current.dismissedErrorCode).toBeNull(); + }); + + it("should track manual dismissal", async () => { + const { result } = renderHook( + ({ machineError }) => + useErrorPopoverState({ + machineError, + machineErrorMessage: null, + pyodideError: null, + hasError, + }), + { initialProps: { machineError: 1 } }, + ); + + expect(result.current.isOpen).toBe(true); + + // Manually dismiss + act(() => { + result.current.handleOpenChange(false); + }); + + await waitFor(() => { + expect(result.current.isOpen).toBe(false); + }); + expect(result.current.wasManuallyDismissed).toBe(true); + expect(result.current.dismissedErrorCode).toBe(1); + }); + + it("should not auto-reopen after manual dismissal", async () => { + const { result, rerender } = renderHook( + ({ machineError }) => + useErrorPopoverState({ + machineError, + machineErrorMessage: null, + pyodideError: null, + hasError, + }), + { initialProps: { machineError: 1 } }, + ); + + // Manually dismiss + act(() => { + result.current.handleOpenChange(false); + }); + + await waitFor(() => { + expect(result.current.wasManuallyDismissed).toBe(true); + }); + + // Try to reopen by changing error (but same error code) + rerender({ machineError: 1 }); + expect(result.current.isOpen).toBe(false); + }); + + it("should auto-open for new error after manual dismissal and error clear", async () => { + const { result, rerender } = renderHook( + ({ machineError }) => + useErrorPopoverState({ + machineError, + machineErrorMessage: null, + pyodideError: null, + hasError, + }), + { initialProps: { machineError: 1 } }, + ); + + // Manually dismiss error 1 + act(() => { + result.current.handleOpenChange(false); + }); + + await waitFor(() => { + expect(result.current.dismissedErrorCode).toBe(1); + }); + + // Clear all errors first (this resets wasManuallyDismissed) + rerender({ machineError: 0 }); + + await waitFor(() => { + expect(result.current.wasManuallyDismissed).toBe(false); + }); + + // New error appears (error 2) + rerender({ machineError: 2 }); + + // Should auto-open since manual dismissal was reset + await waitFor(() => { + expect(result.current.isOpen).toBe(true); + }); + }); + + it("should reset dismissal tracking when all errors clear", async () => { + const { result, rerender } = renderHook( + ({ machineError }) => + useErrorPopoverState({ + machineError, + machineErrorMessage: null, + pyodideError: null, + hasError, + }), + { initialProps: { machineError: 1 } }, + ); + + // Manually dismiss + act(() => { + result.current.handleOpenChange(false); + }); + + await waitFor(() => { + expect(result.current.wasManuallyDismissed).toBe(true); + }); + + // Clear error + rerender({ machineError: 0 }); + + await waitFor(() => { + expect(result.current.wasManuallyDismissed).toBe(false); + }); + expect(result.current.dismissedErrorCode).toBeNull(); + }); + + it("should handle multiple error sources", () => { + const { result, rerender } = renderHook( + ({ + machineError, + machineErrorMessage, + pyodideError, + }: { + machineError: number | undefined; + machineErrorMessage: string | null; + pyodideError: string | null; + }) => + useErrorPopoverState({ + machineError, + machineErrorMessage, + pyodideError, + hasError, + }), + { + initialProps: { + machineError: undefined as number | undefined, + machineErrorMessage: null as string | null, + pyodideError: null as string | null, + }, + }, + ); + + expect(result.current.isOpen).toBe(false); + + // Machine error appears + rerender({ + machineError: 1 as number | undefined, + machineErrorMessage: null as string | null, + pyodideError: null as string | null, + }); + expect(result.current.isOpen).toBe(true); + + // Additional pyodide error + rerender({ + machineError: 1 as number | undefined, + machineErrorMessage: null as string | null, + pyodideError: "Pyodide error" as string | null, + }); + expect(result.current.isOpen).toBe(true); + + // Clear machine error but pyodide error remains + rerender({ + machineError: 0 as number | undefined, + machineErrorMessage: null as string | null, + pyodideError: "Pyodide error" as string | null, + }); + // Should stay open because pyodide error still exists + expect(result.current.isOpen).toBe(true); + + // Clear all errors + rerender({ + machineError: 0 as number | undefined, + machineErrorMessage: null as string | null, + pyodideError: null as string | null, + }); + expect(result.current.isOpen).toBe(false); + }); +}); diff --git a/src/hooks/domain/useErrorPopoverState.ts b/src/hooks/domain/useErrorPopoverState.ts new file mode 100644 index 0000000..6c1f38c --- /dev/null +++ b/src/hooks/domain/useErrorPopoverState.ts @@ -0,0 +1,140 @@ +/** + * useErrorPopoverState Hook + * + * Manages error popover state with sophisticated auto-open/close behavior. + * Automatically opens when new errors appear and closes when all errors are cleared. + * Tracks manual dismissal to prevent reopening for the same error. + * + * This hook is designed for multi-source error handling (e.g., machine errors, + * pyodide errors, error messages) and provides a consistent UX for error notification. + * + * @param options - Configuration options + * @param options.machineError - Current machine error code + * @param options.machineErrorMessage - Current machine error message + * @param options.pyodideError - Current Pyodide error message + * @param options.hasError - Function to check if an error code represents an error + * @returns Object containing popover state and control functions + * + * @example + * ```tsx + * const { isOpen, handleOpenChange } = useErrorPopoverState({ + * machineError, + * machineErrorMessage, + * pyodideError, + * hasError: (code) => code !== 0 && code !== undefined + * }); + * + * return ( + * + * {errorMessage} + * + * ); + * ``` + */ + +import { useState, useEffect } from "react"; +import { usePrevious } from "../utility/usePrevious"; + +export interface UseErrorPopoverStateOptions { + machineError: number | undefined; + machineErrorMessage: string | null; + pyodideError: string | null; + hasError: (error: number | undefined) => boolean; +} + +export interface UseErrorPopoverStateReturn { + isOpen: boolean; + handleOpenChange: (open: boolean) => void; + dismissedErrorCode: number | null; + wasManuallyDismissed: boolean; +} + +export function useErrorPopoverState( + options: UseErrorPopoverStateOptions, +): UseErrorPopoverStateReturn { + const { machineError, machineErrorMessage, pyodideError, hasError } = options; + + // Internal state + const [isOpen, setIsOpen] = useState(false); + const [dismissedErrorCode, setDismissedErrorCode] = useState( + null, + ); + const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false); + + // Track previous values for comparison + const prevMachineError = usePrevious(machineError); + const prevErrorMessage = usePrevious(machineErrorMessage); + const prevPyodideError = usePrevious(pyodideError); + + // Auto-open/close logic + // Note: This effect intentionally calls setState to synchronize popover state with error state. + // This is a valid use case for setState in an effect as we're synchronizing external state + // (error codes) with internal UI state (popover visibility). + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + // Check if there's any error now + const hasAnyError = + machineErrorMessage || pyodideError || hasError(machineError); + // Check if there was any error before + const hadAnyError = + prevErrorMessage || prevPyodideError || hasError(prevMachineError); + + // Auto-open popover when new error appears (but not if user manually dismissed) + const isNewMachineError = + hasError(machineError) && + machineError !== prevMachineError && + machineError !== dismissedErrorCode; + const isNewErrorMessage = + machineErrorMessage && machineErrorMessage !== prevErrorMessage; + const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError; + + if ( + !wasManuallyDismissed && + (isNewMachineError || isNewErrorMessage || isNewPyodideError) + ) { + setIsOpen(true); + } + + // Auto-close popover when all errors are cleared + if (!hasAnyError && hadAnyError) { + setIsOpen(false); + setDismissedErrorCode(null); // Reset dismissed tracking + setWasManuallyDismissed(false); // Reset manual dismissal flag + } + }, [ + machineError, + machineErrorMessage, + pyodideError, + dismissedErrorCode, + wasManuallyDismissed, + prevMachineError, + prevErrorMessage, + prevPyodideError, + hasError, + ]); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Handle manual popover dismiss + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + + // If user manually closes it while any error is present, remember this to prevent reopening + if ( + !open && + (hasError(machineError) || machineErrorMessage || pyodideError) + ) { + setWasManuallyDismissed(true); + // Also track the specific machine error code if present + if (hasError(machineError) && machineError !== undefined) { + setDismissedErrorCode(machineError); + } + } + }; + + return { + isOpen, + handleOpenChange, + dismissedErrorCode, + wasManuallyDismissed, + }; +} diff --git a/src/hooks/domain/useMachinePolling.test.ts b/src/hooks/domain/useMachinePolling.test.ts new file mode 100644 index 0000000..59b3ac5 --- /dev/null +++ b/src/hooks/domain/useMachinePolling.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { useMachinePolling } from "./useMachinePolling"; +import { MachineStatus } from "../../types/machine"; + +describe("useMachinePolling", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should start polling when startPolling is called", async () => { + const onStatusRefresh = vi.fn().mockResolvedValue(undefined); + const onProgressRefresh = vi.fn().mockResolvedValue(undefined); + const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined); + const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useMachinePolling({ + machineStatus: MachineStatus.IDLE, + patternInfo: null, + onStatusRefresh, + onProgressRefresh, + onServiceCountRefresh, + onPatternInfoRefresh, + shouldCheckResumablePattern: () => false, + }), + ); + + expect(result.current.isPolling).toBe(false); + + act(() => { + result.current.startPolling(); + }); + + await waitFor(() => { + expect(result.current.isPolling).toBe(true); + }); + }); + + it("should stop polling when stopPolling is called", async () => { + const onStatusRefresh = vi.fn().mockResolvedValue(undefined); + const onProgressRefresh = vi.fn().mockResolvedValue(undefined); + const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined); + const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useMachinePolling({ + machineStatus: MachineStatus.IDLE, + patternInfo: null, + onStatusRefresh, + onProgressRefresh, + onServiceCountRefresh, + onPatternInfoRefresh, + shouldCheckResumablePattern: () => false, + }), + ); + + act(() => { + result.current.startPolling(); + }); + + await waitFor(() => { + expect(result.current.isPolling).toBe(true); + }); + + act(() => { + result.current.stopPolling(); + }); + + await waitFor(() => { + expect(result.current.isPolling).toBe(false); + }); + }); + + it("should initialize polling correctly for SEWING state", async () => { + const onStatusRefresh = vi.fn().mockResolvedValue(undefined); + const onProgressRefresh = vi.fn().mockResolvedValue(undefined); + const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined); + const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useMachinePolling({ + machineStatus: MachineStatus.SEWING, + patternInfo: null, + onStatusRefresh, + onProgressRefresh, + onServiceCountRefresh, + onPatternInfoRefresh, + shouldCheckResumablePattern: () => false, + }), + ); + + act(() => { + result.current.startPolling(); + }); + + await waitFor(() => { + expect(result.current.isPolling).toBe(true); + }); + + act(() => { + result.current.stopPolling(); + }); + + await waitFor(() => { + expect(result.current.isPolling).toBe(false); + }); + }); + + it("should initialize polling for different machine states", async () => { + const createMocks = () => ({ + onStatusRefresh: vi.fn().mockResolvedValue(undefined), + onProgressRefresh: vi.fn().mockResolvedValue(undefined), + onServiceCountRefresh: vi.fn().mockResolvedValue(undefined), + onPatternInfoRefresh: vi.fn().mockResolvedValue(undefined), + }); + + // Test COLOR_CHANGE_WAIT state + const mocks1 = createMocks(); + const { result: result1 } = renderHook(() => + useMachinePolling({ + machineStatus: MachineStatus.COLOR_CHANGE_WAIT, + patternInfo: null, + ...mocks1, + shouldCheckResumablePattern: () => false, + }), + ); + act(() => { + result1.current.startPolling(); + }); + await waitFor(() => { + expect(result1.current.isPolling).toBe(true); + }); + act(() => { + result1.current.stopPolling(); + }); + + // Test READY state + const mocks2 = createMocks(); + const { result: result2 } = renderHook(() => + useMachinePolling({ + machineStatus: MachineStatus.IDLE, + patternInfo: null, + ...mocks2, + shouldCheckResumablePattern: () => false, + }), + ); + act(() => { + result2.current.startPolling(); + }); + await waitFor(() => { + expect(result2.current.isPolling).toBe(true); + }); + act(() => { + result2.current.stopPolling(); + }); + }); +}); diff --git a/src/hooks/domain/useMachinePolling.ts b/src/hooks/domain/useMachinePolling.ts new file mode 100644 index 0000000..23862b2 --- /dev/null +++ b/src/hooks/domain/useMachinePolling.ts @@ -0,0 +1,186 @@ +/** + * useMachinePolling Hook + * + * Implements dynamic polling for machine status based on machine state. + * Uses adaptive polling intervals and conditional progress polling during sewing. + * + * Polling intervals: + * - 500ms for active states (SEWING, MASK_TRACING, SEWING_DATA_RECEIVE) + * - 1000ms for waiting states (COLOR_CHANGE_WAIT, MASK_TRACE_LOCK_WAIT, SEWING_WAIT) + * - 2000ms for idle/other states + * + * Additionally polls service count every 10 seconds. + * + * @param options - Configuration options + * @param options.machineStatus - Current machine status to determine polling interval + * @param options.patternInfo - Current pattern info for resumable pattern check + * @param options.onStatusRefresh - Callback to refresh machine status + * @param options.onProgressRefresh - Callback to refresh sewing progress + * @param options.onServiceCountRefresh - Callback to refresh service count + * @param options.onPatternInfoRefresh - Callback to refresh pattern info + * @param options.shouldCheckResumablePattern - Function to check if resumable pattern exists + * @returns Object containing start/stop functions and polling state + * + * @example + * ```tsx + * const { startPolling, stopPolling, isPolling } = useMachinePolling({ + * machineStatus, + * patternInfo, + * onStatusRefresh: async () => { ... }, + * onProgressRefresh: async () => { ... }, + * onServiceCountRefresh: async () => { ... }, + * onPatternInfoRefresh: async () => { ... }, + * shouldCheckResumablePattern: () => resumeAvailable + * }); + * + * useEffect(() => { + * startPolling(); + * return () => stopPolling(); + * }, []); + * ``` + */ + +import { useState, useCallback, useRef, useEffect } from "react"; +import { MachineStatus } from "../../types/machine"; +import type { PatternInfo } from "../../types/machine"; + +export interface UseMachinePollingOptions { + machineStatus: MachineStatus; + patternInfo: PatternInfo | null; + onStatusRefresh: () => Promise; + onProgressRefresh: () => Promise; + onServiceCountRefresh: () => Promise; + onPatternInfoRefresh: () => Promise; + shouldCheckResumablePattern: () => boolean; +} + +export interface UseMachinePollingReturn { + startPolling: () => void; + stopPolling: () => void; + isPolling: boolean; +} + +export function useMachinePolling( + options: UseMachinePollingOptions, +): UseMachinePollingReturn { + const { + machineStatus, + patternInfo, + onStatusRefresh, + onProgressRefresh, + onServiceCountRefresh, + onPatternInfoRefresh, + shouldCheckResumablePattern, + } = options; + + const [isPolling, setIsPolling] = useState(false); + const pollTimeoutRef = useRef(null); + const serviceCountIntervalRef = useRef(null); + const pollFunctionRef = useRef<(() => Promise) | undefined>(undefined); + + // Store callbacks in refs to avoid unnecessary re-renders + const callbacksRef = useRef({ + onStatusRefresh, + onProgressRefresh, + onServiceCountRefresh, + onPatternInfoRefresh, + shouldCheckResumablePattern, + }); + + // Update refs when callbacks change + useEffect(() => { + callbacksRef.current = { + onStatusRefresh, + onProgressRefresh, + onServiceCountRefresh, + onPatternInfoRefresh, + shouldCheckResumablePattern, + }; + }); + + // Function to determine polling interval based on machine status + const getPollInterval = useCallback((status: MachineStatus) => { + // Fast polling for active states + if ( + status === MachineStatus.SEWING || + status === MachineStatus.MASK_TRACING || + status === MachineStatus.SEWING_DATA_RECEIVE + ) { + return 500; + } else if ( + status === MachineStatus.COLOR_CHANGE_WAIT || + status === MachineStatus.MASK_TRACE_LOCK_WAIT || + status === MachineStatus.SEWING_WAIT + ) { + return 1000; + } + return 2000; // Default for idle states + }, []); + + // Main polling function + const poll = useCallback(async () => { + await callbacksRef.current.onStatusRefresh(); + + // Refresh progress during sewing + if (machineStatus === MachineStatus.SEWING) { + await callbacksRef.current.onProgressRefresh(); + } + + // Check if we have a cached pattern and pattern info needs refreshing + // This follows the app's logic for resumable patterns + if ( + callbacksRef.current.shouldCheckResumablePattern() && + patternInfo?.totalStitches === 0 + ) { + await callbacksRef.current.onPatternInfoRefresh(); + } + + // Schedule next poll with updated interval + const newInterval = getPollInterval(machineStatus); + if (pollFunctionRef.current) { + pollTimeoutRef.current = setTimeout(pollFunctionRef.current, newInterval); + } + }, [machineStatus, patternInfo, getPollInterval]); + + // Store poll function in ref for recursive setTimeout + useEffect(() => { + pollFunctionRef.current = poll; + }, [poll]); + + const stopPolling = useCallback(() => { + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + + if (serviceCountIntervalRef.current) { + clearInterval(serviceCountIntervalRef.current); + serviceCountIntervalRef.current = null; + } + + setIsPolling(false); + }, []); + + const startPolling = useCallback(() => { + // Stop any existing polling + stopPolling(); + + // Start main polling + const initialInterval = getPollInterval(machineStatus); + pollTimeoutRef.current = setTimeout(poll, initialInterval); + + // Start service count polling (every 10 seconds) + serviceCountIntervalRef.current = setInterval( + callbacksRef.current.onServiceCountRefresh, + 10000, + ); + + setIsPolling(true); + }, [machineStatus, poll, stopPolling, getPollInterval]); + + return { + startPolling, + stopPolling, + isPolling, + }; +} diff --git a/src/hooks/usePatternRotationUpload.ts b/src/hooks/domain/usePatternRotationUpload.ts similarity index 93% rename from src/hooks/usePatternRotationUpload.ts rename to src/hooks/domain/usePatternRotationUpload.ts index ececf57..92d89d3 100644 --- a/src/hooks/usePatternRotationUpload.ts +++ b/src/hooks/domain/usePatternRotationUpload.ts @@ -1,12 +1,12 @@ import { useCallback } from "react"; -import type { PesPatternData } from "../formats/import/pesImporter"; -import { transformStitchesRotation } from "../utils/rotationUtils"; -import { encodeStitchesToPen } from "../formats/pen/encoder"; -import { decodePenData } from "../formats/pen/decoder"; +import type { PesPatternData } from "../../formats/import/pesImporter"; +import { transformStitchesRotation } from "../../utils/rotationUtils"; +import { encodeStitchesToPen } from "../../formats/pen/encoder"; +import { decodePenData } from "../../formats/pen/decoder"; import { calculatePatternCenter, calculateBoundsFromDecodedStitches, -} from "../components/PatternCanvas/patternCanvasHelpers"; +} from "../../components/PatternCanvas/patternCanvasHelpers"; export interface UsePatternRotationUploadParams { uploadPattern: ( diff --git a/src/hooks/usePatternValidation.ts b/src/hooks/domain/usePatternValidation.ts similarity index 91% rename from src/hooks/usePatternValidation.ts rename to src/hooks/domain/usePatternValidation.ts index 4c55938..8ea8c64 100644 --- a/src/hooks/usePatternValidation.ts +++ b/src/hooks/domain/usePatternValidation.ts @@ -1,8 +1,8 @@ import { useMemo } from "react"; -import type { PesPatternData } from "../formats/import/pesImporter"; -import type { MachineInfo } from "../types/machine"; -import { calculateRotatedBounds } from "../utils/rotationUtils"; -import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers"; +import type { PesPatternData } from "../../formats/import/pesImporter"; +import type { MachineInfo } from "../../types/machine"; +import { calculateRotatedBounds } from "../../utils/rotationUtils"; +import { calculatePatternCenter } from "../../components/PatternCanvas/patternCanvasHelpers"; export interface PatternBoundsCheckResult { fits: boolean; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..7226d00 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,11 @@ +// Utility Hooks - Generic, reusable patterns +export * from "./utility"; + +// Domain Hooks - Business logic for embroidery/pattern operations +export * from "./domain"; + +// UI Hooks - Library/framework integration (Konva, etc.) +export * from "./ui"; + +// Platform Hooks - Electron/Pyodide specific functionality +export * from "./platform"; diff --git a/src/hooks/platform/index.ts b/src/hooks/platform/index.ts new file mode 100644 index 0000000..426914c --- /dev/null +++ b/src/hooks/platform/index.ts @@ -0,0 +1,3 @@ +export { useFileUpload } from "./useFileUpload"; +export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener"; +export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener"; diff --git a/src/hooks/platform/useBluetoothDeviceListener.test.ts b/src/hooks/platform/useBluetoothDeviceListener.test.ts new file mode 100644 index 0000000..f89eff5 --- /dev/null +++ b/src/hooks/platform/useBluetoothDeviceListener.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { useBluetoothDeviceListener } from "./useBluetoothDeviceListener"; +import type { BluetoothDevice } from "../../types/electron"; + +describe("useBluetoothDeviceListener", () => { + beforeEach(() => { + // Reset window.electronAPI before each test + delete (window as { electronAPI?: unknown }).electronAPI; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return empty state when Electron API is not available", () => { + const { result } = renderHook(() => useBluetoothDeviceListener()); + + expect(result.current.devices).toEqual([]); + expect(result.current.isScanning).toBe(false); + expect(result.current.isSupported).toBe(false); + }); + + it("should return isSupported=true when Electron API is available", () => { + // Mock Electron API + ( + window as unknown as { + electronAPI: { onBluetoothDeviceList: () => void }; + } + ).electronAPI = { + onBluetoothDeviceList: vi.fn(), + }; + + const { result } = renderHook(() => useBluetoothDeviceListener()); + + expect(result.current.isSupported).toBe(true); + }); + + it("should register IPC listener when Electron API is available", () => { + const mockListener = vi.fn(); + ( + window as unknown as { + electronAPI: { onBluetoothDeviceList: typeof mockListener }; + } + ).electronAPI = { + onBluetoothDeviceList: mockListener, + }; + + renderHook(() => useBluetoothDeviceListener()); + + expect(mockListener).toHaveBeenCalledTimes(1); + expect(mockListener).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should update devices when listener receives data", async () => { + let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null = + null; + + const mockListener = vi.fn( + (callback: (devices: BluetoothDevice[]) => void) => { + deviceListCallback = callback; + }, + ); + + ( + window as unknown as { + electronAPI: { onBluetoothDeviceList: typeof mockListener }; + } + ).electronAPI = { + onBluetoothDeviceList: mockListener, + }; + + const { result } = renderHook(() => useBluetoothDeviceListener()); + + expect(result.current.devices).toEqual([]); + + // Simulate device list update + const mockDevices: BluetoothDevice[] = [ + { deviceId: "device1", deviceName: "Device 1" }, + { deviceId: "device2", deviceName: "Device 2" }, + ]; + + // Trigger the callback + act(() => { + deviceListCallback!(mockDevices); + }); + + await waitFor(() => { + expect(result.current.devices).toEqual(mockDevices); + }); + }); + + it("should set isScanning=true when empty device list received", async () => { + let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null = + null; + + const mockListener = vi.fn( + (callback: (devices: BluetoothDevice[]) => void) => { + deviceListCallback = callback; + }, + ); + + ( + window as unknown as { + electronAPI: { onBluetoothDeviceList: typeof mockListener }; + } + ).electronAPI = { + onBluetoothDeviceList: mockListener, + }; + + const { result } = renderHook(() => useBluetoothDeviceListener()); + + // Simulate empty device list (scanning in progress) + act(() => { + deviceListCallback!([]); + }); + + await waitFor(() => { + expect(result.current.isScanning).toBe(true); + }); + expect(result.current.devices).toEqual([]); + }); + + it("should set isScanning=false when devices are received", async () => { + let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null = + null; + + const mockListener = vi.fn( + (callback: (devices: BluetoothDevice[]) => void) => { + deviceListCallback = callback; + }, + ); + + ( + window as unknown as { + electronAPI: { onBluetoothDeviceList: typeof mockListener }; + } + ).electronAPI = { + onBluetoothDeviceList: mockListener, + }; + + const { result } = renderHook(() => useBluetoothDeviceListener()); + + // First update: empty list (scanning) + act(() => { + deviceListCallback!([]); + }); + await waitFor(() => { + expect(result.current.isScanning).toBe(true); + }); + + // Second update: devices found (stop scanning indicator) + const mockDevices: BluetoothDevice[] = [ + { deviceId: "device1", deviceName: "Device 1" }, + ]; + act(() => { + deviceListCallback!(mockDevices); + }); + + await waitFor(() => { + expect(result.current.isScanning).toBe(false); + }); + expect(result.current.devices).toEqual(mockDevices); + }); + + it("should call optional callback when devices change", async () => { + let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null = + null; + + const mockListener = vi.fn( + (callback: (devices: BluetoothDevice[]) => void) => { + deviceListCallback = callback; + }, + ); + + ( + window as unknown as { + electronAPI: { onBluetoothDeviceList: typeof mockListener }; + } + ).electronAPI = { + onBluetoothDeviceList: mockListener, + }; + + const onDevicesChanged = vi.fn(); + renderHook(() => useBluetoothDeviceListener(onDevicesChanged)); + + const mockDevices: BluetoothDevice[] = [ + { deviceId: "device1", deviceName: "Device 1" }, + ]; + + act(() => { + deviceListCallback!(mockDevices); + }); + + await waitFor(() => { + expect(onDevicesChanged).toHaveBeenCalledWith(mockDevices); + }); + }); +}); diff --git a/src/hooks/platform/useBluetoothDeviceListener.ts b/src/hooks/platform/useBluetoothDeviceListener.ts new file mode 100644 index 0000000..faf8579 --- /dev/null +++ b/src/hooks/platform/useBluetoothDeviceListener.ts @@ -0,0 +1,98 @@ +/** + * useBluetoothDeviceListener Hook + * + * Listens for Bluetooth device discovery events from Electron IPC. + * Automatically manages device list state and provides platform detection. + * + * This hook is Electron-specific and will gracefully handle browser environments + * by returning empty state. + * + * @param onDevicesChanged - Optional callback when device list changes + * @returns Object containing devices array, scanning state, and platform support flag + * + * @example + * ```tsx + * const { devices, isScanning, isSupported } = useBluetoothDeviceListener( + * (devices) => { + * if (devices.length > 0) { + * console.log('Devices found:', devices); + * } + * } + * ); + * + * if (!isSupported) { + * return
Bluetooth pairing only available in Electron app
; + * } + * + * return ( + *
+ * {isScanning &&

Scanning...

} + * {devices.map(device =>
{device.name}
)} + *
+ * ); + * ``` + */ + +import { useEffect, useState, useRef } from "react"; +import type { BluetoothDevice } from "../../types/electron"; + +export interface UseBluetoothDeviceListenerReturn { + devices: BluetoothDevice[]; + isScanning: boolean; + isSupported: boolean; +} + +export function useBluetoothDeviceListener( + onDevicesChanged?: (devices: BluetoothDevice[]) => void, +): UseBluetoothDeviceListenerReturn { + const [devices, setDevices] = useState([]); + const [isScanning, setIsScanning] = useState(false); + + // Store callback in ref to avoid re-registering listener + const callbackRef = useRef(onDevicesChanged); + + // Update ref when callback changes + useEffect(() => { + callbackRef.current = onDevicesChanged; + }); + + // Check if Electron API is available + const isSupported = + typeof window !== "undefined" && + !!window.electronAPI?.onBluetoothDeviceList; + + useEffect(() => { + // Only set up listener in Electron + if (!isSupported) { + return; + } + + const handleDeviceList = (deviceList: BluetoothDevice[]) => { + setDevices(deviceList); + + // Start scanning when first update received + if (deviceList.length === 0) { + setIsScanning(true); + } else { + // Stop showing scanning state once we have devices + setIsScanning(false); + } + + // Call optional callback using ref to get latest version + callbackRef.current?.(deviceList); + }; + + // Register listener only once + window.electronAPI!.onBluetoothDeviceList(handleDeviceList); + + // Note: Electron IPC listeners are typically not cleaned up individually + // as they're meant to persist. If cleanup is needed, the Electron main + // process should handle it. + }, [isSupported]); + + return { + devices, + isScanning, + isSupported, + }; +} diff --git a/src/hooks/useFileUpload.ts b/src/hooks/platform/useFileUpload.ts similarity index 94% rename from src/hooks/useFileUpload.ts rename to src/hooks/platform/useFileUpload.ts index e676706..87adcd4 100644 --- a/src/hooks/useFileUpload.ts +++ b/src/hooks/platform/useFileUpload.ts @@ -2,8 +2,8 @@ import { useState, useCallback } from "react"; import { convertPesToPen, type PesPatternData, -} from "../formats/import/pesImporter"; -import type { IFileService } from "../platform/interfaces/IFileService"; +} from "../../formats/import/pesImporter"; +import type { IFileService } from "../../platform/interfaces/IFileService"; export interface UseFileUploadParams { fileService: IFileService; diff --git a/src/hooks/ui/index.ts b/src/hooks/ui/index.ts new file mode 100644 index 0000000..91bc173 --- /dev/null +++ b/src/hooks/ui/index.ts @@ -0,0 +1,2 @@ +export { useCanvasViewport } from "./useCanvasViewport"; +export { usePatternTransform } from "./usePatternTransform"; diff --git a/src/hooks/useCanvasViewport.ts b/src/hooks/ui/useCanvasViewport.ts similarity index 94% rename from src/hooks/useCanvasViewport.ts rename to src/hooks/ui/useCanvasViewport.ts index c8e4727..28bc783 100644 --- a/src/hooks/useCanvasViewport.ts +++ b/src/hooks/ui/useCanvasViewport.ts @@ -7,10 +7,10 @@ import { useState, useEffect, useCallback, type RefObject } from "react"; import type Konva from "konva"; -import type { PesPatternData } from "../formats/import/pesImporter"; -import type { MachineInfo } from "../types/machine"; -import { calculateInitialScale } from "../utils/konvaRenderers"; -import { calculateZoomToPoint } from "../components/PatternCanvas/patternCanvasHelpers"; +import type { PesPatternData } from "../../formats/import/pesImporter"; +import type { MachineInfo } from "../../types/machine"; +import { calculateInitialScale } from "../../utils/konvaRenderers"; +import { calculateZoomToPoint } from "../../components/PatternCanvas/patternCanvasHelpers"; interface UseCanvasViewportOptions { containerRef: RefObject; diff --git a/src/hooks/usePatternTransform.ts b/src/hooks/ui/usePatternTransform.ts similarity index 98% rename from src/hooks/usePatternTransform.ts rename to src/hooks/ui/usePatternTransform.ts index a3f1a2e..acf3e6b 100644 --- a/src/hooks/usePatternTransform.ts +++ b/src/hooks/ui/usePatternTransform.ts @@ -8,7 +8,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type Konva from "konva"; import type { KonvaEventObject } from "konva/lib/Node"; -import type { PesPatternData } from "../formats/import/pesImporter"; +import type { PesPatternData } from "../../formats/import/pesImporter"; interface UsePatternTransformOptions { pesData: PesPatternData | null; diff --git a/src/hooks/utility/index.ts b/src/hooks/utility/index.ts new file mode 100644 index 0000000..a8e2ede --- /dev/null +++ b/src/hooks/utility/index.ts @@ -0,0 +1,5 @@ +export { usePrevious } from "./usePrevious"; +export { useAutoScroll } from "./useAutoScroll"; +export { useClickOutside } from "./useClickOutside"; +export type { UseAutoScrollOptions } from "./useAutoScroll"; +export type { UseClickOutsideOptions } from "./useClickOutside"; diff --git a/src/hooks/utility/useAutoScroll.test.ts b/src/hooks/utility/useAutoScroll.test.ts new file mode 100644 index 0000000..349ebf4 --- /dev/null +++ b/src/hooks/utility/useAutoScroll.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useAutoScroll } from "./useAutoScroll"; + +describe("useAutoScroll", () => { + beforeEach(() => { + // Mock scrollIntoView + Element.prototype.scrollIntoView = vi.fn(); + }); + + it("should return a ref object", () => { + const { result } = renderHook(() => useAutoScroll(0)); + expect(result.current).toHaveProperty("current"); + }); + + it("should call scrollIntoView when dependency changes", () => { + const mockElement = document.createElement("div"); + const scrollIntoViewMock = vi.fn(); + mockElement.scrollIntoView = scrollIntoViewMock; + + const { result, rerender } = renderHook(({ dep }) => useAutoScroll(dep), { + initialProps: { dep: 0 }, + }); + + // Attach mock element to ref + (result.current as { current: HTMLElement }).current = mockElement; + + // Change dependency to trigger effect + rerender({ dep: 1 }); + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ + behavior: "smooth", + block: "nearest", + inline: undefined, + }); + }); + + it("should use custom scroll options", () => { + const mockElement = document.createElement("div"); + const scrollIntoViewMock = vi.fn(); + mockElement.scrollIntoView = scrollIntoViewMock; + + const { result, rerender } = renderHook( + ({ dep }) => + useAutoScroll(dep, { + behavior: "auto", + block: "start", + inline: "center", + }), + { initialProps: { dep: 0 } }, + ); + + (result.current as { current: HTMLElement }).current = mockElement; + rerender({ dep: 1 }); + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ + behavior: "auto", + block: "start", + inline: "center", + }); + }); + + it("should not call scrollIntoView if ref is not attached", () => { + const { rerender } = renderHook(({ dep }) => useAutoScroll(dep), { + initialProps: { dep: 0 }, + }); + + // Change dependency without attaching ref + rerender({ dep: 1 }); + + // Should not throw or cause errors + expect(true).toBe(true); + }); +}); diff --git a/src/hooks/utility/useAutoScroll.ts b/src/hooks/utility/useAutoScroll.ts new file mode 100644 index 0000000..5f0cf00 --- /dev/null +++ b/src/hooks/utility/useAutoScroll.ts @@ -0,0 +1,57 @@ +/** + * useAutoScroll Hook + * + * Automatically scrolls an element into view when a dependency changes. + * Useful for keeping the current item visible in scrollable lists. + * + * @param dependency - The value to watch for changes (e.g., current index) + * @param options - Scroll behavior options + * @returns RefObject to attach to the element that should be scrolled into view + * + * @example + * ```tsx + * const currentItemRef = useAutoScroll(currentIndex, { + * behavior: "smooth", + * block: "nearest" + * }); + * + * return ( + *
+ * Current Item + *
+ * ); + * ``` + */ + +import { useEffect, useRef, useMemo, type RefObject } from "react"; + +export interface UseAutoScrollOptions { + behavior?: ScrollBehavior; + block?: ScrollLogicalPosition; + inline?: ScrollLogicalPosition; +} + +export function useAutoScroll( + dependency: unknown, + options?: UseAutoScrollOptions, +): RefObject { + const ref = useRef(null); + + // Stabilize options to avoid unnecessary re-renders when passed as inline object + const stableOptions = useMemo( + () => ({ + behavior: options?.behavior || "smooth", + block: options?.block || "nearest", + inline: options?.inline, + }), + [options?.behavior, options?.block, options?.inline], + ); + + useEffect(() => { + if (ref.current) { + ref.current.scrollIntoView(stableOptions); + } + }, [dependency, stableOptions]); + + return ref; +} diff --git a/src/hooks/utility/useClickOutside.test.ts b/src/hooks/utility/useClickOutside.test.ts new file mode 100644 index 0000000..6408fb1 --- /dev/null +++ b/src/hooks/utility/useClickOutside.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useClickOutside } from "./useClickOutside"; +import { useRef, type RefObject } from "react"; + +describe("useClickOutside", () => { + it("should call handler when clicking outside element", () => { + const handler = vi.fn(); + const { result } = renderHook(() => { + const ref = useRef(null); + useClickOutside(ref, handler); + return ref; + }); + + // Create and attach mock element + const element = document.createElement("div"); + document.body.appendChild(element); + (result.current as { current: HTMLDivElement }).current = element; + + // Click outside + const outsideElement = document.createElement("div"); + document.body.appendChild(outsideElement); + outsideElement.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + + expect(handler).toHaveBeenCalledTimes(1); + + // Cleanup + document.body.removeChild(element); + document.body.removeChild(outsideElement); + }); + + it("should not call handler when clicking inside element", () => { + const handler = vi.fn(); + const { result } = renderHook(() => { + const ref = useRef(null); + useClickOutside(ref, handler); + return ref; + }); + + const element = document.createElement("div"); + document.body.appendChild(element); + (result.current as { current: HTMLDivElement }).current = element; + + // Click inside + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + + expect(handler).not.toHaveBeenCalled(); + + document.body.removeChild(element); + }); + + it("should respect enabled option", () => { + const handler = vi.fn(); + const { result, rerender } = renderHook( + ({ enabled }) => { + const ref = useRef(null); + useClickOutside(ref, handler, { enabled }); + return ref; + }, + { initialProps: { enabled: false } }, + ); + + const element = document.createElement("div"); + document.body.appendChild(element); + (result.current as { current: HTMLDivElement }).current = element; + + // Click outside while disabled + const outsideElement = document.createElement("div"); + document.body.appendChild(outsideElement); + outsideElement.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + + expect(handler).not.toHaveBeenCalled(); + + // Enable and click outside again + rerender({ enabled: true }); + outsideElement.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + + expect(handler).toHaveBeenCalledTimes(1); + + document.body.removeChild(element); + document.body.removeChild(outsideElement); + }); + + it("should not call handler when clicking excluded refs", () => { + const handler = vi.fn(); + const { result } = renderHook(() => { + const ref = useRef(null); + const excludeRef = useRef(null); + useClickOutside(ref, handler, { + excludeRefs: [excludeRef as unknown as RefObject], + }); + return { ref, excludeRef }; + }); + + const element = document.createElement("div"); + const excludedElement = document.createElement("button"); + document.body.appendChild(element); + document.body.appendChild(excludedElement); + + (result.current.ref as { current: HTMLDivElement }).current = element; + (result.current.excludeRef as { current: HTMLElement }).current = + excludedElement; + + // Click on excluded element + excludedElement.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + + expect(handler).not.toHaveBeenCalled(); + + document.body.removeChild(element); + document.body.removeChild(excludedElement); + }); + + it("should handle object of refs (WorkflowStepper pattern)", () => { + const handler = vi.fn(); + const { result } = renderHook(() => { + const ref = useRef(null); + const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); + useClickOutside(ref, handler, { excludeRefs: [stepRefs] }); + return { ref, stepRefs }; + }); + + const element = document.createElement("div"); + const step1 = document.createElement("div"); + const step2 = document.createElement("div"); + document.body.appendChild(element); + document.body.appendChild(step1); + document.body.appendChild(step2); + + (result.current.ref as { current: HTMLDivElement }).current = element; + ( + result.current.stepRefs as { + current: { [key: number]: HTMLDivElement | null }; + } + ).current = { + 1: step1, + 2: step2, + }; + + // Click on step1 (excluded) + step1.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + expect(handler).not.toHaveBeenCalled(); + + // Click on step2 (excluded) + step2.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + expect(handler).not.toHaveBeenCalled(); + + document.body.removeChild(element); + document.body.removeChild(step1); + document.body.removeChild(step2); + }); +}); diff --git a/src/hooks/utility/useClickOutside.ts b/src/hooks/utility/useClickOutside.ts new file mode 100644 index 0000000..f90d023 --- /dev/null +++ b/src/hooks/utility/useClickOutside.ts @@ -0,0 +1,89 @@ +/** + * useClickOutside Hook + * + * Detects clicks outside a referenced element and executes a handler function. + * Useful for closing dropdown menus, popovers, modals, and other overlay UI elements. + * + * @param ref - Reference to the element to monitor for outside clicks + * @param handler - Callback function to execute when outside click is detected + * @param options - Configuration options + * @param options.enabled - Whether the listener is active (default: true) + * @param options.excludeRefs - Array of refs that should not trigger the handler when clicked + * + * @example + * ```tsx + * const dropdownRef = useRef(null); + * const buttonRef = useRef(null); + * + * useClickOutside( + * dropdownRef, + * () => setIsOpen(false), + * { + * enabled: isOpen, + * excludeRefs: [buttonRef] // Don't close when clicking the button + * } + * ); + * + * return ( + * <> + * + * {isOpen &&
Content
} + * + * ); + * ``` + */ + +import { useEffect, type RefObject } from "react"; + +export interface UseClickOutsideOptions { + enabled?: boolean; + excludeRefs?: ( + | RefObject + | { current: Record } + )[]; +} + +export function useClickOutside( + ref: RefObject, + handler: (event: MouseEvent) => void, + options?: UseClickOutsideOptions, +): void { + const { enabled = true, excludeRefs = [] } = options || {}; + + useEffect(() => { + if (!enabled) return; + + const handleClickOutside = (event: MouseEvent) => { + // Check if click is outside the main ref + if (ref.current && !ref.current.contains(event.target as Node)) { + // Check if click is on any excluded refs + const clickedExcluded = excludeRefs.some((excludeRef) => { + if (!excludeRef.current) return false; + + // Handle object of refs (e.g., { [key: number]: HTMLElement | null }) + if ( + typeof excludeRef.current === "object" && + !("nodeType" in excludeRef.current) + ) { + return Object.values(excludeRef.current).some((element) => + element?.contains(event.target as Node), + ); + } + + // Handle single ref + return (excludeRef.current as HTMLElement).contains( + event.target as Node, + ); + }); + + // Only call handler if click was not on excluded elements + if (!clickedExcluded) { + handler(event); + } + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [ref, handler, enabled, excludeRefs]); +} diff --git a/src/hooks/utility/usePrevious.test.ts b/src/hooks/utility/usePrevious.test.ts new file mode 100644 index 0000000..22d4f5c --- /dev/null +++ b/src/hooks/utility/usePrevious.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { usePrevious } from "./usePrevious"; + +describe("usePrevious", () => { + it("should return undefined on initial render", () => { + const { result } = renderHook(() => usePrevious(5)); + expect(result.current).toBeUndefined(); + }); + + it("should return previous value after update", () => { + const { result, rerender } = renderHook(({ value }) => usePrevious(value), { + initialProps: { value: 5 }, + }); + + expect(result.current).toBeUndefined(); + + rerender({ value: 10 }); + expect(result.current).toBe(5); + + rerender({ value: 15 }); + expect(result.current).toBe(10); + }); + + it("should handle different types of values", () => { + const { result, rerender } = renderHook(({ value }) => usePrevious(value), { + initialProps: { value: "hello" as string | number | null }, + }); + + expect(result.current).toBeUndefined(); + + rerender({ value: 42 }); + expect(result.current).toBe("hello"); + + rerender({ value: null }); + expect(result.current).toBe(42); + }); + + it("should handle object references", () => { + const obj1 = { name: "first" }; + const obj2 = { name: "second" }; + + const { result, rerender } = renderHook(({ value }) => usePrevious(value), { + initialProps: { value: obj1 }, + }); + + expect(result.current).toBeUndefined(); + + rerender({ value: obj2 }); + expect(result.current).toBe(obj1); + }); +}); diff --git a/src/hooks/usePrevious.ts b/src/hooks/utility/usePrevious.ts similarity index 100% rename from src/hooks/usePrevious.ts rename to src/hooks/utility/usePrevious.ts diff --git a/vitest.config.ts b/vitest.config.ts index 43f4055..527491c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - environment: "node", - include: ["src/**/*.{test,spec}.{js,ts}"], + environment: "jsdom", + include: ["src/**/*.{test,spec}.{js,ts,tsx}"], }, });