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:
@@ -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}
|
||||
|
||||
48
packages/abacus-react/src/test/debug-columns-test.tsx
Normal file
48
packages/abacus-react/src/test/debug-columns-test.tsx
Normal 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
|
||||
});
|
||||
});
|
||||
246
packages/abacus-react/src/test/gesture-and-input.test.tsx
Normal file
246
packages/abacus-react/src/test/gesture-and-input.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user