fix: resolve react-doctor errors (hooks order, reduced motion, key prop) (#105)
- Move useCallback hooks before early return in ReferencesDialog to fix conditional hook call violation (Rules of Hooks) - Add useReducedMotion hook and apply to all motion animations for WCAG 2.3.3 accessibility compliance - Replace useEffect state reset with key prop on ItemCatalog for proper React reconciliation on category change - Add global prefers-reduced-motion CSS media query Fixes 4 react-doctor errors, bringing score from 81 to ~85+.
This commit is contained in:
@@ -144,3 +144,14 @@
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ControlModes } from "./control-modes";
|
||||
import { PhaseSwitcher } from "./phase-switcher";
|
||||
import { StructureTools } from "./structure-tools";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { useReducedMotion } from "@/hooks/use-reduced-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ItemCatalog } from "../item-catalog/item-catalog";
|
||||
import { FurnishTools } from "./furnish-tools";
|
||||
@@ -18,6 +19,8 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const tool = useEditor((state) => state.tool);
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const transition = reducedMotion ? { duration: 0 } : undefined;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -57,8 +60,9 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<ItemCatalog category={catalogCategory} />
|
||||
<ItemCatalog key={catalogCategory} category={catalogCategory} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -91,6 +95,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<div className="mx-auto w-max">
|
||||
<FurnishTools />
|
||||
@@ -127,6 +132,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<div className="w-max">
|
||||
<StructureTools />
|
||||
|
||||
@@ -21,12 +21,6 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null);
|
||||
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null);
|
||||
|
||||
// Reset tag filters when category changes
|
||||
useEffect(() => {
|
||||
setActivePlacementTag(null);
|
||||
setActiveFunctionalTag(null);
|
||||
}, [category]);
|
||||
|
||||
const categoryItems = CATALOG_ITEMS.filter(
|
||||
(item) => item.category === category,
|
||||
);
|
||||
|
||||
@@ -38,15 +38,6 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
||||
const scanInputRef = useRef<HTMLInputElement>(null)
|
||||
const guideInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
||||
if (!level) return null
|
||||
|
||||
// Find all scan and guide children of this level
|
||||
const references = Object.values(nodes).filter(
|
||||
(node): node is ScanNode | GuideNode =>
|
||||
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
||||
)
|
||||
|
||||
const handleAddScan = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -100,6 +91,15 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
||||
[deleteNode],
|
||||
)
|
||||
|
||||
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
||||
if (!level) return null
|
||||
|
||||
// Find all scan and guide children of this level
|
||||
const references = Object.values(nodes).filter(
|
||||
(node): node is ScanNode | GuideNode =>
|
||||
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Returns true when the user has requested reduced motion via OS settings.
|
||||
* Useful for disabling animations (WCAG 2.3.3).
|
||||
*/
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reducedMotion, setReducedMotion] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReducedMotion(mql.matches)
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
|
||||
mql.addEventListener('change', handler)
|
||||
return () => mql.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return reducedMotion
|
||||
}
|
||||
Reference in New Issue
Block a user