Merge pull request #55 from jhbruhn/feature/40-comprehensive-hooks-library

feature: Create comprehensive custom hooks library (Issue #40)
This commit is contained in:
Jan-Henrik Bruhn 2025-12-27 13:05:19 +01:00 committed by GitHub
commit ec19426dd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2594 additions and 155 deletions

View file

@ -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.

View file

@ -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": []

11
.mcp.json Normal file
View file

@ -0,0 +1,11 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

698
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<number | null>(
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 (
<TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">

View file

@ -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<BluetoothDevice[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isScanning, setIsScanning] = useState(false);
useEffect(() => {
// Only set up listener in Electron
if (window.electronAPI?.onBluetoothDeviceList) {
window.electronAPI.onBluetoothDeviceList((deviceList) => {
// Use Bluetooth device listener hook
const { devices, isScanning } = useBluetoothDeviceListener((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);
// 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(() => {
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 (

View file

@ -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 {

View file

@ -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

View file

@ -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<HTMLDivElement>(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<HTMLDivElement>(currentBlockIndex);
return (
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">

View file

@ -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<HTMLDivElement>(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<HTMLDivElement>(popoverRef, () => setShowPopover(false), {
enabled: showPopover,
excludeRefs: [stepRefs],
});
const handleStepClick = (stepId: number) => {
// Only allow clicking on current step or earlier completed steps

12
src/hooks/domain/index.ts Normal file
View file

@ -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";

View file

@ -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);
});
});

View file

@ -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 (
* <Popover open={isOpen} onOpenChange={handleOpenChange}>
* <PopoverContent>{errorMessage}</PopoverContent>
* </Popover>
* );
* ```
*/
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<number | null>(
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,
};
}

View file

@ -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();
});
});
});

View file

@ -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<void>;
onProgressRefresh: () => Promise<void>;
onServiceCountRefresh: () => Promise<void>;
onPatternInfoRefresh: () => Promise<void>;
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<NodeJS.Timeout | null>(null);
const serviceCountIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pollFunctionRef = useRef<(() => Promise<void>) | 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,
};
}

View file

@ -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: (

View file

@ -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;

11
src/hooks/index.ts Normal file
View file

@ -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";

View file

@ -0,0 +1,3 @@
export { useFileUpload } from "./useFileUpload";
export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener";

View file

@ -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);
});
});
});

View file

@ -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 <div>Bluetooth pairing only available in Electron app</div>;
* }
*
* return (
* <div>
* {isScanning && <p>Scanning...</p>}
* {devices.map(device => <div key={device.id}>{device.name}</div>)}
* </div>
* );
* ```
*/
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<BluetoothDevice[]>([]);
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,
};
}

View file

@ -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;

2
src/hooks/ui/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { useCanvasViewport } from "./useCanvasViewport";
export { usePatternTransform } from "./usePatternTransform";

View file

@ -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<HTMLDivElement | null>;

View file

@ -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;

View file

@ -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";

View file

@ -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);
});
});

View file

@ -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 (
* <div ref={isCurrent ? currentItemRef : null}>
* Current Item
* </div>
* );
* ```
*/
import { useEffect, useRef, useMemo, type RefObject } from "react";
export interface UseAutoScrollOptions {
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
}
export function useAutoScroll<T extends HTMLElement = HTMLElement>(
dependency: unknown,
options?: UseAutoScrollOptions,
): RefObject<T | null> {
const ref = useRef<T>(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;
}

View file

@ -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<HTMLDivElement>(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<HTMLDivElement>(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<HTMLDivElement>(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<HTMLDivElement>(null);
const excludeRef = useRef<HTMLElement>(null);
useClickOutside(ref, handler, {
excludeRefs: [excludeRef as unknown as RefObject<HTMLElement>],
});
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<HTMLDivElement>(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);
});
});

View file

@ -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<HTMLDivElement>(null);
* const buttonRef = useRef<HTMLButtonElement>(null);
*
* useClickOutside(
* dropdownRef,
* () => setIsOpen(false),
* {
* enabled: isOpen,
* excludeRefs: [buttonRef] // Don't close when clicking the button
* }
* );
*
* return (
* <>
* <button ref={buttonRef}>Toggle</button>
* {isOpen && <div ref={dropdownRef}>Content</div>}
* </>
* );
* ```
*/
import { useEffect, type RefObject } from "react";
export interface UseClickOutsideOptions {
enabled?: boolean;
excludeRefs?: (
| RefObject<HTMLElement>
| { current: Record<string, HTMLElement | null> }
)[];
}
export function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T | null>,
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]);
}

View file

@ -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);
});
});

View file

@ -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}"],
},
});