mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
Merge pull request #55 from jhbruhn/feature/40-comprehensive-hooks-library
feature: Create comprehensive custom hooks library (Issue #40)
This commit is contained in:
commit
ec19426dd1
34 changed files with 2594 additions and 155 deletions
286
.claude/agents/react-specialist.md
Normal file
286
.claude/agents/react-specialist.md
Normal 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.
|
||||
|
|
@ -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
11
.mcp.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
698
package-lock.json
generated
698
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 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 (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
12
src/hooks/domain/index.ts
Normal 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";
|
||||
280
src/hooks/domain/useErrorPopoverState.test.ts
Normal file
280
src/hooks/domain/useErrorPopoverState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
140
src/hooks/domain/useErrorPopoverState.ts
Normal file
140
src/hooks/domain/useErrorPopoverState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
158
src/hooks/domain/useMachinePolling.test.ts
Normal file
158
src/hooks/domain/useMachinePolling.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
186
src/hooks/domain/useMachinePolling.ts
Normal file
186
src/hooks/domain/useMachinePolling.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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: (
|
||||
|
|
@ -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
11
src/hooks/index.ts
Normal 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";
|
||||
3
src/hooks/platform/index.ts
Normal file
3
src/hooks/platform/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useFileUpload } from "./useFileUpload";
|
||||
export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
|
||||
export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener";
|
||||
199
src/hooks/platform/useBluetoothDeviceListener.test.ts
Normal file
199
src/hooks/platform/useBluetoothDeviceListener.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/hooks/platform/useBluetoothDeviceListener.ts
Normal file
98
src/hooks/platform/useBluetoothDeviceListener.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
2
src/hooks/ui/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useCanvasViewport } from "./useCanvasViewport";
|
||||
export { usePatternTransform } from "./usePatternTransform";
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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;
|
||||
5
src/hooks/utility/index.ts
Normal file
5
src/hooks/utility/index.ts
Normal 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";
|
||||
74
src/hooks/utility/useAutoScroll.test.ts
Normal file
74
src/hooks/utility/useAutoScroll.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
src/hooks/utility/useAutoScroll.ts
Normal file
57
src/hooks/utility/useAutoScroll.ts
Normal 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;
|
||||
}
|
||||
159
src/hooks/utility/useClickOutside.test.ts
Normal file
159
src/hooks/utility/useClickOutside.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
89
src/hooks/utility/useClickOutside.ts
Normal file
89
src/hooks/utility/useClickOutside.ts
Normal 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]);
|
||||
}
|
||||
52
src/hooks/utility/usePrevious.test.ts
Normal file
52
src/hooks/utility/usePrevious.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue