fix: implement focus handling for numeral input in place-value system

- Add tabIndex and focus/blur handlers to abacus-container
- Fix Backspace functionality to clear current column before navigation
- Separate Backspace (clear + move) from Shift+Tab (move only)
- Fix setColumnValue parameter issue (remove duplicate placeValue field)
- Enable basic numeral input functionality (2/8 tests now passing)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-21 17:18:28 -05:00
parent 689bfd5df1
commit 415759c43b
3 changed files with 334 additions and 10 deletions

View File

@@ -1113,7 +1113,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// Switch to place-value architecture!
const maxPlaceValue = (effectiveColumns - 1) as ValidPlaceValues;
const { value: currentValue, placeStates, toggleBead } = useAbacusPlaceStates(value, maxPlaceValue);
const { value: currentValue, placeStates, toggleBead, getPlaceState, setPlaceState } = useAbacusPlaceStates(value, maxPlaceValue);
// Legacy compatibility - convert placeStates back to columnStates for components that still need it
const columnStates = useMemo(() => {
@@ -1196,13 +1196,12 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
}, [onClick, callbacks, toggleBead, disabledColumns, disabledBeads]);
const handleGestureToggle = useCallback((bead: BeadConfig, direction: 'activate' | 'deactivate') => {
const columnIndex = effectiveColumns - 1 - bead.placeValue; // Convert place value to column index
const currentState = columnStates[columnIndex];
const currentState = getPlaceState(bead.placeValue);
if (bead.type === 'heaven') {
// Heaven bead: directly set the state based on direction
const newHeavenActive = direction === 'activate';
setColumnState(columnIndex, {
setPlaceState(bead.placeValue, {
...currentState,
heavenActive: newHeavenActive
});
@@ -1219,12 +1218,12 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
newEarthActive = Math.min(currentState.earthActive, bead.position);
}
setColumnState(columnIndex, {
setPlaceState(bead.placeValue, {
...currentState,
earthActive: newEarthActive
});
}
}, [columnStates, setColumnState, effectiveColumns]);
}, [getPlaceState, setPlaceState]);
// Place value editing - FRESH IMPLEMENTATION
const [activeColumn, setActiveColumn] = React.useState<number | null>(null);
@@ -1240,11 +1239,14 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
const setColumnValue = React.useCallback((columnIndex: number, digit: number) => {
if (digit < 0 || digit > 9) return;
setColumnState(columnIndex, {
// Convert column index to place value
const placeValue = (effectiveColumns - 1 - columnIndex) as ValidPlaceValues;
setPlaceState(placeValue, {
heavenActive: digit >= 5,
earthActive: digit % 5
});
}, [setColumnState]);
}, [setPlaceState, effectiveColumns]);
// Keyboard handler - only active when interactive
React.useEffect(() => {
@@ -1283,9 +1285,12 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
} else {
// console.log(`🏁 Reached last column, staying at: ${activeColumn}`);
}
} else if (e.key === 'Backspace' || (e.key === 'Tab' && e.shiftKey)) {
} else if (e.key === 'Backspace') {
e.preventDefault();
// console.log(`⬅️ ${e.key === 'Backspace' ? 'BACKSPACE' : 'SHIFT+TAB'}: moving to previous column`);
// console.log(`⬅️ BACKSPACE: clearing current column and moving to previous column`);
// Clear current column (set to 0)
setColumnValue(activeColumn, 0);
// Move focus to the previous column to the left
const prevColumn = activeColumn - 1;
@@ -1296,6 +1301,19 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// console.log(`🏁 Reached first column, wrapping to last column`);
setActiveColumn(effectiveColumns - 1); // Wrap around to last column
}
} else if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
// console.log(`⬅️ SHIFT+TAB: moving to previous column`);
// Move focus to the previous column to the left (without clearing)
const prevColumn = activeColumn - 1;
if (prevColumn >= 0) {
// console.log(`⬅️ Moving focus to previous column: ${prevColumn}`);
setActiveColumn(prevColumn);
} else {
// console.log(`🏁 Reached first column, wrapping to last column`);
setActiveColumn(effectiveColumns - 1); // Wrap around to last column
}
} else if (e.key === 'Tab') {
e.preventDefault();
// console.log(`🔄 TAB: moving to next column`);
@@ -1333,6 +1351,18 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
<div
className="abacus-container"
style={{ display: 'inline-block', textAlign: 'center', position: 'relative' }}
tabIndex={finalConfig.interactive && finalConfig.showNumbers ? 0 : undefined}
onFocus={() => {
if (finalConfig.interactive && finalConfig.showNumbers && activeColumn === null) {
// Start at the rightmost column (ones place)
setActiveColumn(effectiveColumns - 1);
}
}}
onBlur={() => {
if (finalConfig.interactive && finalConfig.showNumbers) {
setActiveColumn(null);
}
}}
>
<svg
width={dimensions.width}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { AbacusReact } from '../AbacusReact';
describe('Debug Columns Test', () => {
it('should render value=3 with columns=3 correctly', () => {
const { container } = render(
<AbacusReact value={3} columns={3} interactive={true} />
);
// Debug: log all testids to see what's happening
const allBeads = container.querySelectorAll('[data-testid]');
console.log('All bead testids:');
allBeads.forEach(bead => {
const testId = bead.getAttribute('data-testid');
const isActive = bead.classList.contains('active');
console.log(` ${testId} - active: ${isActive}`);
});
// Check that we have beads in all 3 places
const place0Beads = container.querySelectorAll('[data-testid*="bead-place-0-"]');
const place1Beads = container.querySelectorAll('[data-testid*="bead-place-1-"]');
const place2Beads = container.querySelectorAll('[data-testid*="bead-place-2-"]');
console.log(`Place 0 beads: ${place0Beads.length}`);
console.log(`Place 1 beads: ${place1Beads.length}`);
console.log(`Place 2 beads: ${place2Beads.length}`);
// For value 3 with 3 columns, we should have:
// - Place 0 (ones): 3 active earth beads
// - Place 1 (tens): all inactive (no beads needed for tens place)
// - Place 2 (hundreds): all inactive (no beads needed for hundreds place)
// We should have beads in all 3 places
expect(place0Beads.length).toBeGreaterThan(0); // ones place
expect(place1Beads.length).toBeGreaterThan(0); // tens place
expect(place2Beads.length).toBeGreaterThan(0); // hundreds place
// Check active beads - only place 0 should have active beads
const activePlaceZero = container.querySelectorAll('[data-testid*="bead-place-0-"].active');
const activePlaceOne = container.querySelectorAll('[data-testid*="bead-place-1-"].active');
const activePlaceTwo = container.querySelectorAll('[data-testid*="bead-place-2-"].active');
expect(activePlaceZero).toHaveLength(3); // 3 active earth beads for ones
expect(activePlaceOne).toHaveLength(0); // no active beads for tens
expect(activePlaceTwo).toHaveLength(0); // no active beads for hundreds
});
});

View File

@@ -0,0 +1,246 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { AbacusReact } from '../AbacusReact';
describe('Gesture and Input Functionality', () => {
describe('Gesture Support', () => {
it('should handle heaven bead gesture activation', () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={2}
interactive={true}
gestures={true}
onValueChange={onValueChange}
/>
);
// Find a heaven bead in place 0 (ones place)
const heavenBead = container.querySelector('[data-testid="bead-place-0-heaven"]');
expect(heavenBead).toBeTruthy();
// Simulate gesture activation (would normally be a drag gesture)
// We'll simulate by finding the bead component and calling its gesture handler
const beadElement = heavenBead as HTMLElement;
// Simulate a drag gesture to activate the heaven bead (drag up)
fireEvent.mouseDown(beadElement, { clientY: 100 });
fireEvent.mouseMove(beadElement, { clientY: 80, buttons: 1 }); // Move up while dragging
fireEvent.mouseUp(beadElement, { clientY: 80 });
// The value should change from 0 to 5 (heaven bead activated)
expect(onValueChange).toHaveBeenCalledWith(5);
});
it('should handle earth bead gesture activation', () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={2}
interactive={true}
gestures={true}
onValueChange={onValueChange}
/>
);
// Find the first earth bead in place 0 (ones place)
const earthBead = container.querySelector('[data-testid="bead-place-0-earth-pos-0"]');
expect(earthBead).toBeTruthy();
const beadElement = earthBead as HTMLElement;
// Simulate a drag gesture to activate the earth bead (drag up)
fireEvent.mouseDown(beadElement, { clientY: 150 });
fireEvent.mouseMove(beadElement, { clientY: 130, buttons: 1 }); // Move up while dragging
fireEvent.mouseUp(beadElement, { clientY: 130 });
// The value should change from 0 to 1 (first earth bead activated)
expect(onValueChange).toHaveBeenCalledWith(1);
});
it('should handle gesture deactivation', () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={5}
columns={2}
interactive={true}
gestures={true}
onValueChange={onValueChange}
/>
);
// Find the active heaven bead in place 0
const heavenBead = container.querySelector('[data-testid="bead-place-0-heaven"].active');
expect(heavenBead).toBeTruthy();
const beadElement = heavenBead as HTMLElement;
// Simulate a drag gesture to deactivate the heaven bead (drag down)
fireEvent.mouseDown(beadElement, { clientY: 80 });
fireEvent.mouseMove(beadElement, { clientY: 100, buttons: 1 }); // Move down while dragging
fireEvent.mouseUp(beadElement, { clientY: 100 });
// The value should change from 5 to 0 (heaven bead deactivated)
expect(onValueChange).toHaveBeenCalledWith(0);
});
});
describe('Numeral Input', () => {
it('should allow typing digits to change values', () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={3}
interactive={true}
showNumbers={true}
onValueChange={onValueChange}
/>
);
// Find the abacus container (should be focusable for keyboard input)
const abacusContainer = container.querySelector('.abacus-container');
expect(abacusContainer).toBeTruthy();
// Focus the abacus and type a digit
fireEvent.focus(abacusContainer!);
fireEvent.keyDown(abacusContainer!, { key: '7' });
// The value should change to 7 in the ones place
expect(onValueChange).toHaveBeenCalledWith(7);
});
it('should allow navigating between columns with Tab', () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={3}
interactive={true}
showNumbers={true}
onValueChange={onValueChange}
/>
);
const abacusContainer = container.querySelector('.abacus-container');
expect(abacusContainer).toBeTruthy();
// Focus and type in ones place
fireEvent.focus(abacusContainer!);
fireEvent.keyDown(abacusContainer!, { key: '3' });
expect(onValueChange).toHaveBeenLastCalledWith(3);
// Move to tens place with Tab
fireEvent.keyDown(abacusContainer!, { key: 'Tab' });
fireEvent.keyDown(abacusContainer!, { key: '2' });
expect(onValueChange).toHaveBeenLastCalledWith(23);
// Move to hundreds place with Tab
fireEvent.keyDown(abacusContainer!, { key: 'Tab' });
fireEvent.keyDown(abacusContainer!, { key: '1' });
expect(onValueChange).toHaveBeenLastCalledWith(123);
});
it('should allow navigating backwards with Shift+Tab', () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={123}
columns={3}
interactive={true}
showNumbers={true}
onValueChange={onValueChange}
/>
);
const abacusContainer = container.querySelector('.abacus-container');
expect(abacusContainer).toBeTruthy();
// Focus the abacus (should start at rightmost/ones place)
fireEvent.focus(abacusContainer!);
// Move left to tens place
fireEvent.keyDown(abacusContainer!, { key: 'Tab', shiftKey: true });
fireEvent.keyDown(abacusContainer!, { key: '5' });
expect(onValueChange).toHaveBeenLastCalledWith(153);
// Move left to hundreds place
fireEvent.keyDown(abacusContainer!, { key: 'Tab', shiftKey: true });
fireEvent.keyDown(abacusContainer!, { key: '9' });
expect(onValueChange).toHaveBeenLastCalledWith(953);
});
it('should use Backspace to clear current column and move left', () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={123}
columns={3}
interactive={true}
showNumbers={true}
onValueChange={onValueChange}
/>
);
const abacusContainer = container.querySelector('.abacus-container');
expect(abacusContainer).toBeTruthy();
// Focus the abacus (should start at rightmost/ones place with value 3)
fireEvent.focus(abacusContainer!);
// Backspace should clear ones place (3 -> 0) and move to tens
fireEvent.keyDown(abacusContainer!, { key: 'Backspace' });
expect(onValueChange).toHaveBeenLastCalledWith(120);
// Next digit should go in tens place
fireEvent.keyDown(abacusContainer!, { key: '4' });
expect(onValueChange).toHaveBeenLastCalledWith(140);
});
});
describe('Integration Tests', () => {
it('should work with both gestures and numeral input on same abacus', () => {
const onValueChange = vi.fn();
const { container } = render(
<AbacusReact
value={0}
columns={2}
interactive={true}
gestures={true}
showNumbers={true}
onValueChange={onValueChange}
/>
);
// First use numeral input
const abacusContainer = container.querySelector('.abacus-container');
fireEvent.focus(abacusContainer!);
fireEvent.keyDown(abacusContainer!, { key: '3' });
expect(onValueChange).toHaveBeenLastCalledWith(3);
// Then use gesture to modify tens place
fireEvent.keyDown(abacusContainer!, { key: 'Tab' }); // Move to tens
const heavenBead = container.querySelector('[data-testid="bead-place-1-heaven"]');
expect(heavenBead).toBeTruthy();
const beadElement = heavenBead as HTMLElement;
fireEvent.mouseDown(beadElement, { clientY: 100 });
fireEvent.mouseMove(beadElement, { clientY: 80, buttons: 1 }); // Drag up to activate
fireEvent.mouseUp(beadElement, { clientY: 80 });
// Should now have 50 + 3 = 53
expect(onValueChange).toHaveBeenLastCalledWith(53);
});
});
});