fix: implement proper React controlled input pattern for AbacusReact
- Make useAbacusPlaceStates hook responsive to external value changes - Add proper controlled input behavior to prevent feedback loops - Track external vs user-initiated changes to avoid calling onValueChange for programmatic updates - Allows parent components to control the abacus value while still permitting user interaction 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -414,7 +414,7 @@ export function useAbacusState(initialValue: number = 0, targetColumns?: number)
|
||||
}
|
||||
|
||||
// NEW: Native place-value state management hook (eliminates the column index nightmare!)
|
||||
export function useAbacusPlaceStates(initialValue: number = 0, maxPlaceValue: ValidPlaceValues = 4) {
|
||||
export function useAbacusPlaceStates(controlledValue: number = 0, maxPlaceValue: ValidPlaceValues = 4) {
|
||||
// Initialize state from value using place values as keys - NO MORE ARRAY INDICES!
|
||||
const initializeFromValue = useCallback((value: number): PlaceStatesMap => {
|
||||
const states = new Map<ValidPlaceValues, PlaceState>();
|
||||
@@ -434,7 +434,7 @@ export function useAbacusPlaceStates(initialValue: number = 0, maxPlaceValue: Va
|
||||
return states;
|
||||
}, [maxPlaceValue]);
|
||||
|
||||
const [placeStates, setPlaceStates] = useState<PlaceStatesMap>(() => initializeFromValue(initialValue));
|
||||
const [placeStates, setPlaceStates] = useState<PlaceStatesMap>(() => initializeFromValue(controlledValue));
|
||||
|
||||
// Calculate current value from place states - NO MORE INDEX MATH!
|
||||
const value = useMemo(() => {
|
||||
@@ -451,6 +451,16 @@ export function useAbacusPlaceStates(initialValue: number = 0, maxPlaceValue: Va
|
||||
setPlaceStates(initializeFromValue(newValue));
|
||||
}, [initializeFromValue]);
|
||||
|
||||
// Update internal state when external controlled value changes
|
||||
// Only update if the controlled value is different from our current value
|
||||
// This prevents infinite loops while allowing controlled updates
|
||||
React.useEffect(() => {
|
||||
const currentInternalValue = value;
|
||||
if (controlledValue !== currentInternalValue) {
|
||||
setPlaceStates(initializeFromValue(controlledValue));
|
||||
}
|
||||
}, [controlledValue, initializeFromValue, value]);
|
||||
|
||||
const getPlaceState = useCallback((placeValue: ValidPlaceValues): PlaceState => {
|
||||
return placeStates.get(placeValue) || {
|
||||
placeValue,
|
||||
@@ -1345,16 +1355,42 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
}
|
||||
}, [placeStates, effectiveColumns]);
|
||||
|
||||
// Debug prop changes
|
||||
// Track when changes are from external control vs user interaction
|
||||
const isExternalChange = useRef(false);
|
||||
const previousControlledValue = useRef(value);
|
||||
|
||||
// Debug prop changes and mark external changes
|
||||
React.useEffect(() => {
|
||||
// console.log(`🔄 Component received value prop: ${value}, internal value: ${currentValue}`);
|
||||
|
||||
// Mark this as an external change when controlled value prop changes
|
||||
if (value !== previousControlledValue.current) {
|
||||
isExternalChange.current = true;
|
||||
}
|
||||
}, [value, currentValue]);
|
||||
|
||||
// Notify about value changes
|
||||
// Notify about value changes only when user interacts (not external control)
|
||||
React.useEffect(() => {
|
||||
// Skip callback if this change was from external control
|
||||
if (isExternalChange.current) {
|
||||
isExternalChange.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip callback if value hasn't actually changed from user interaction
|
||||
if (currentValue === previousControlledValue.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a user-initiated change, notify parent
|
||||
onValueChange?.(currentValue);
|
||||
}, [currentValue, onValueChange]);
|
||||
|
||||
// Track controlled value changes
|
||||
React.useEffect(() => {
|
||||
previousControlledValue.current = value;
|
||||
}, [value]);
|
||||
|
||||
|
||||
const dimensions = useAbacusDimensions(effectiveColumns, finalConfig.scaleFactor, finalConfig.showNumbers);
|
||||
|
||||
@@ -1509,23 +1545,9 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
}
|
||||
} else if (e.key === 'Tab' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
// console.log(`➡️ SHIFT+TAB: moving to lower place value (right)`);
|
||||
// console.log(`⬅️ SHIFT+TAB: moving to higher place value (left)`);
|
||||
|
||||
// Shift+Tab moves RIGHT (to lower place values): hundreds → tens → ones
|
||||
// Higher columnIndex = lower place value
|
||||
const nextColumn = activeColumn + 1;
|
||||
if (nextColumn < effectiveColumns) {
|
||||
// console.log(`➡️ Moving focus to lower place value: ${nextColumn}`);
|
||||
setActiveColumn(nextColumn);
|
||||
} else {
|
||||
// console.log(`🏁 Reached lowest place, wrapping to highest place`);
|
||||
setActiveColumn(0); // Wrap to leftmost (highest place)
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
// console.log(`🔄 TAB: moving to next higher place value (left)`);
|
||||
|
||||
// Tab moves LEFT (to higher place values): ones → tens → hundreds
|
||||
// Shift+Tab moves LEFT (to higher place values): ones → tens → hundreds
|
||||
// Lower columnIndex = higher place value
|
||||
const nextColumn = activeColumn - 1;
|
||||
if (nextColumn >= 0) {
|
||||
@@ -1535,6 +1557,20 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
// console.log(`🏁 Reached highest place, wrapping to ones place`);
|
||||
setActiveColumn(effectiveColumns - 1); // Wrap to rightmost (ones place)
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
// console.log(`➡️ TAB: moving to lower place value (right)`);
|
||||
|
||||
// Tab moves RIGHT (to lower place values): hundreds → tens → ones
|
||||
// Higher columnIndex = lower place value
|
||||
const nextColumn = activeColumn + 1;
|
||||
if (nextColumn < effectiveColumns) {
|
||||
// console.log(`➡️ Moving focus to lower place value: ${nextColumn}`);
|
||||
setActiveColumn(nextColumn);
|
||||
} else {
|
||||
// console.log(`🏁 Reached lowest place, wrapping to highest place`);
|
||||
setActiveColumn(0); // Wrap to leftmost (highest place)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// console.log(`🚪 ESCAPE: setting activeColumn to null`);
|
||||
|
||||
Reference in New Issue
Block a user