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:
Anton
2026-02-19 19:20:05 +00:00
committed by GitHub
parent 9a0f83a1ec
commit ff85765728
5 changed files with 47 additions and 16 deletions
+11
View File
@@ -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">
+20
View File
@@ -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
}