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 */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
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 { PhaseSwitcher } from "./phase-switcher";
|
||||||
import { StructureTools } from "./structure-tools";
|
import { StructureTools } from "./structure-tools";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
|
import { useReducedMotion } from "@/hooks/use-reduced-motion";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { ItemCatalog } from "../item-catalog/item-catalog";
|
import { ItemCatalog } from "../item-catalog/item-catalog";
|
||||||
import { FurnishTools } from "./furnish-tools";
|
import { FurnishTools } from "./furnish-tools";
|
||||||
@@ -18,6 +19,8 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
const mode = useEditor((state) => state.mode);
|
const mode = useEditor((state) => state.mode);
|
||||||
const tool = useEditor((state) => state.tool);
|
const tool = useEditor((state) => state.tool);
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
const catalogCategory = useEditor((state) => state.catalogCategory);
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const transition = reducedMotion ? { duration: 0 } : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -57,8 +60,9 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
borderBottomWidth: 0,
|
borderBottomWidth: 0,
|
||||||
}}
|
}}
|
||||||
|
transition={transition}
|
||||||
>
|
>
|
||||||
<ItemCatalog category={catalogCategory} />
|
<ItemCatalog key={catalogCategory} category={catalogCategory} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -91,6 +95,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
borderBottomWidth: 0,
|
borderBottomWidth: 0,
|
||||||
}}
|
}}
|
||||||
|
transition={transition}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-max">
|
<div className="mx-auto w-max">
|
||||||
<FurnishTools />
|
<FurnishTools />
|
||||||
@@ -127,6 +132,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
borderBottomWidth: 0,
|
borderBottomWidth: 0,
|
||||||
}}
|
}}
|
||||||
|
transition={transition}
|
||||||
>
|
>
|
||||||
<div className="w-max">
|
<div className="w-max">
|
||||||
<StructureTools />
|
<StructureTools />
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
|||||||
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null);
|
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null);
|
||||||
const [activeFunctionalTag, setActiveFunctionalTag] = 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(
|
const categoryItems = CATALOG_ITEMS.filter(
|
||||||
(item) => item.category === category,
|
(item) => item.category === category,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,15 +38,6 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
|||||||
const scanInputRef = useRef<HTMLInputElement>(null)
|
const scanInputRef = useRef<HTMLInputElement>(null)
|
||||||
const guideInputRef = 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(
|
const handleAddScan = useCallback(
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
@@ -100,6 +91,15 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial
|
|||||||
[deleteNode],
|
[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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<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