diff --git a/src/Crossword.md b/src/Crossword.md index 3e6cce0..f95945b 100644 --- a/src/Crossword.md +++ b/src/Crossword.md @@ -144,6 +144,6 @@ const data = { };
- +
; ``` diff --git a/src/CrosswordProvider.tsx b/src/CrosswordProvider.tsx index 8d8633c..86cafcd 100644 --- a/src/CrosswordProvider.tsx +++ b/src/CrosswordProvider.tsx @@ -166,6 +166,11 @@ export const crosswordProviderPropTypes = { */ onClueSelected: PropTypes.func, + /** + * whether to automatically advance to the next incomplete clue (or jump to the first incomplete cell in the current clue) when entering the final character of a clue + */ + autoJumpFromClueEnd: PropTypes.bool, + children: PropTypes.node, }; @@ -179,6 +184,11 @@ export type CrosswordProviderProps = EnhancedProps< */ data: CluesInput; + /** + * whether to automatically advance to the next incomplete clue (or jump to the first incomplete cell in the current clue) when entering the final character of a clue + */ + autoJumpFromClueEnd?: boolean; + /** * callback function that fires when a player completes an answer, whether * correct or not; called with `(direction, number, correct, answer)` @@ -342,6 +352,7 @@ const CrosswordProvider = React.forwardRef< onClueSelected, useStorage, storageKey, + autoJumpFromClueEnd, children, }, ref @@ -659,14 +670,147 @@ const CrosswordProvider = React.forwardRef< const across = isAcross(currentDirection); moveRelative(across ? 0 : -1, across ? -1 : 0); }, [currentDirection, moveRelative]); + /** Advances to the next open cell; wraps around to clues in the other direction, then earlier clues in the current direction + * If all clues are complete, jumps to the first cell of the next clue + */ + const jumpToNextOpenCell = useCallback(() => { + const other = otherDirection(currentDirection); + let target = null; + let targetDirection = currentDirection; + + // Find next incomplete clue in current direction + const currentClues = clues?.[currentDirection] || []; + const currentClueIndex = currentClues.findIndex( + (c) => c.number === currentNumber + ); + + // Look for incomplete clues after current position + const nextIncomplete = currentClues + .slice(currentClueIndex + 1) + .find((c) => !c.complete); + if (nextIncomplete) { + target = nextIncomplete; + } else { + // Look for incomplete clues in other direction + const otherClues = clues?.[other] || []; + const firstIncomplete = otherClues.find((c) => !c.complete); + + if (firstIncomplete) { + target = firstIncomplete; + targetDirection = other; + } else { + // Look for incomplete clues before current position in original direction + const wrappedIncomplete = currentClues + .slice(0, currentClueIndex) + .find((c) => !c.complete); + + if (wrappedIncomplete) { + target = wrappedIncomplete; + } + } + } + + if (target) { + // Find first empty cell in the target clue + const info = data[targetDirection][target.number]; + const { row, col, answer } = info; + const across = isAcross(targetDirection); + let foundEmpty = false; + + for (let i = 0; i < answer.length; i++) { + const checkRow = row + (across ? 0 : i); + const checkCol = col + (across ? i : 0); + const cell = getCellData(checkRow, checkCol) as UsedCellData; + + if (!cell.guess) { + // Found first empty cell, move to it + moveTo(checkRow, checkCol, targetDirection); + foundEmpty = true; + break; + } + } + + // If we haven't found an empty cell, move to start of clue + if (!foundEmpty) { + moveTo(row, col, targetDirection); + } + } + // If all clues are complete, jump to the first cell of the next clue + else if (currentClueIndex + 1 < currentClues.length) { + // Find next clue in current direction + const nextClue = currentClues[currentClueIndex + 1]; + // Move to first cell of next clue + const info = data[currentDirection][nextClue.number]; + moveTo(info.row, info.col, currentDirection); + } else { + // If no next clue in current direction, try other direction + const otherClues = clues?.[other] || []; + if (otherClues.length > 0) { + const firstClue = otherClues[0]; + const info = data[other][firstClue.number]; + moveTo(info.row, info.col, other); + } + } + }, [currentDirection, clues, currentNumber, getCellData, moveTo]); // keyboard handling const handleSingleCharacter = useCallback( (char: string) => { setCellCharacter(focusedRow, focusedCol, char.toUpperCase()); - moveForward(); + + // Only check for auto-advance if the feature is enabled + if (autoJumpFromClueEnd) { + // Check if we're on the last cell of the current clue + const info = data[currentDirection][currentNumber]; + const { row, col, answer } = info; + const across = isAcross(currentDirection); + + // Calculate our position within the clue + const cluePos = across ? focusedCol - col : focusedRow - row; + + // If we're on the last cell of the clue + if (cluePos === answer.length - 1) { + // Check if current clue is complete + let isComplete = true; + for (let i = 0; i < answer.length - 1; i++) { + const checkRow = row + (across ? 0 : i); + const checkCol = col + (across ? i : 0); + const cell = getCellData(checkRow, checkCol) as UsedCellData; + + if (!cell.guess) { + isComplete = false; + // Jump back to first open cell in the current clue + moveTo(checkRow, checkCol, currentDirection); + break; + } + } + + if (isComplete) { + // If complete, jump to next incomplete clue + jumpToNextOpenCell(); + } + } else { + // Not at end of clue, just move forward + moveForward(); + } + } else { + // If auto-advance is disabled, just move forward + moveForward(); + } }, - [focusedRow, focusedCol, setCellCharacter, moveForward] + [ + focusedRow, + focusedCol, + setCellCharacter, + moveForward, + jumpToNextOpenCell, + currentDirection, + currentNumber, + data, + getCellData, + moveTo, + autoJumpFromClueEnd, + ] ); // We use the keydown event for control/arrow keys, but not for textual @@ -702,9 +846,8 @@ const CrosswordProvider = React.forwardRef< case 'ArrowRight': moveRelative(0, 1); break; - - case ' ': // treat space like tab? - case 'Tab': { + // Spacebard switches direction if there is a clue + case ' ': { const other = otherDirection(currentDirection); const cellData = getCellData( focusedRow, @@ -716,6 +859,12 @@ const CrosswordProvider = React.forwardRef< } break; } + // Tab jumps to the next open cell in the next incomplete clue + case 'Tab': { + jumpToNextOpenCell(); + event.preventDefault(); + break; + } // Backspace: delete the current cell, and move to the previous cell // Delete: delete the current cell, but don't move @@ -1133,5 +1282,6 @@ CrosswordProvider.defaultProps = { onCrosswordCorrect: undefined, onCellChange: undefined, onClueSelected: undefined, + autoJumpFromClueEnd: false, children: undefined, }; diff --git a/src/__test__/CrosswordProvider.test.tsx b/src/__test__/CrosswordProvider.test.tsx index 24fbee4..90f5f46 100644 --- a/src/__test__/CrosswordProvider.test.tsx +++ b/src/__test__/CrosswordProvider.test.tsx @@ -165,39 +165,6 @@ describe('keyboard navigation', () => { expect(y).toBe('20.125'); }); - it('tab switches direction (across to down)', async () => { - const { getByLabelText, getByText, user } = setup( - - ); - const input = getByLabelText('crossword-input'); - - await user.click(getByLabelText('clue-1-across')); - fireEvent.keyDown(input, { key: 'End' }); - - fireEvent.keyDown(input, { key: 'Tab' }); // switches to 2-down - fireEvent.keyDown(input, { key: 'End' }); - fireEvent.keyDown(input, { key: 'X' }); - const { x, y } = posForText(getByText('X')); - expect(x).toBe('20.125'); - expect(y).toBe('20.125'); - }); - - it('tab switches direction (down to across)', async () => { - const { getByLabelText, getByText, user } = setup( - - ); - const input = getByLabelText('crossword-input'); - - await user.click(getByLabelText('clue-2-down')); - - fireEvent.keyDown(input, { key: 'Tab' }); // switches to 1-across - fireEvent.keyDown(input, { key: 'Home' }); - fireEvent.keyDown(input, { key: 'X' }); - const { x, y } = posForText(getByText('X')); - expect(x).toBe('0.125'); - expect(y).toBe('0.125'); - }); - it('space switches direction (across to down)', async () => { const { getByLabelText, getByText, user } = setup( @@ -441,6 +408,72 @@ describe('keyboard navigation', () => { expect(x).toBe('20.125'); expect(y).toBe('0.125'); }); + + describe('auto-advance on clue completion', () => { + it('advances to next incomplete clue when completing an across clue', async () => { + const { getByLabelText, getByText, user } = setup( + + ); + const input = getByLabelText('crossword-input'); + + // Start at 1-across + await user.click(getByLabelText('clue-1-across')); + + // Type TWO, should auto-advance to 3-across + await user.type(input, 'TWO', { skipClick: true }); + + // Type X to verify position + fireEvent.keyDown(input, { key: 'X' }); + const { x, y } = posForText(getByText('X')); + + // Should be at start of "NO" clue + expect(x).toBe('20.125'); + expect(y).toBe('10.125'); // Second row + }); + + it('advances to next incomplete clue when completing a down clue', async () => { + const { getByLabelText, getByText, user } = setup( + + ); + const input = getByLabelText('crossword-input'); + + // Start at 2-down + await user.click(getByLabelText('clue-2-down')); + + // Type ONE, should auto-advance to 1-across + await user.type(input, 'ONE', { skipClick: true }); + + // Type X to verify position + fireEvent.keyDown(input, { key: 'X' }); + const { x, y } = posForText(getByText('X')); + + // Should be at start of first across clue + expect(x).toBe('0.125'); + expect(y).toBe('0.125'); + }); + + it('progresses to the next clue once the grid is full', async () => { + const { getByLabelText, getByText, user } = setup( + + ); + const input = getByLabelText('crossword-input'); + + // Complete the puzzle + await user.click(getByLabelText('clue-1-across')); + await user.type(input, 'TWONOE', { skipClick: true }); + // Type X to verify position at 0,0 and Y where we should wrap to clue 3-across + await user.type(input, 'XIIY', { skipClick: true }); + const { x, y } = posForText(getByText('X')); + + // Should have wrapped back to start position + expect(x).toBe('0.125'); + expect(y).toBe('0.125'); + + const { x: x2, y: y2 } = posForText(getByText('Y')); + expect(x2).toBe('20.125'); + expect(y2).toBe('10.125'); + }); + }); }); describe('onAnswerComplete', () => { @@ -511,7 +544,7 @@ describe('onAnswerComplete', () => { expect(onAnswerCorrect).toBeCalledTimes(0); - fireEvent.keyDown(input, { key: 'Tab' }); // switches to 2-down + fireEvent.keyDown(input, { key: ' ' }); // switches to 2-down (changed from Tab) expect(onAnswerCorrect).toBeCalledTimes(0); }); }); @@ -941,3 +974,129 @@ function posForText(textEl: HTMLElement) { const rect = textEl!.parentElement!.firstChild! as SVGRectElement; return { x: rect.getAttribute('x'), y: rect.getAttribute('y') }; } + +describe('tab navigation', () => { + it('tab moves to next incomplete clue in current direction', async () => { + const { getByLabelText, getByText, user } = setup( + + ); + const input = getByLabelText('crossword-input'); + + // Start at 1-across + await user.click(getByLabelText('clue-1-across')); + + // Fill in first answer + await user.type(input, 'TW', { skipClick: true }); + // Tab over to 3-across + fireEvent.keyDown(input, { key: 'Tab' }); + // Type X at the new position to verify we moved + fireEvent.keyDown(input, { key: 'X' }); + const { x, y } = posForText(getByText('X')); + + // Should be at start of "NO" clue + expect(x).toBe('20.125'); + expect(y).toBe('10.125'); // Second row + }); + + it('tab wraps to first incomplete clue in other direction when no more in current direction', async () => { + const { getByLabelText, getByText, user } = setup( + + ); + const input = getByLabelText('crossword-input'); + + // Start at 2-down and begin filling it in + await user.click(getByLabelText('clue-2-down')); + await user.type(input, 'ON', { skipClick: true }); + + fireEvent.keyDown(input, { key: 'Tab' }); // tab to 1-across hopefully + + // Type X at the new position + fireEvent.keyDown(input, { key: 'X' }); + const { x, y } = posForText(getByText('X')); + + // Should be at start of first across clue (1-across, which starts at third column) + expect(x).toBe('0.125'); + expect(y).toBe('0.125'); + }); + + it('tab goes to first cell of next clue when all clues are complete', async () => { + const { getByLabelText, getByText, user } = setup( + + ); + const input = getByLabelText('crossword-input'); + + // Start at and complete 1-across + await user.click(getByLabelText('clue-1-across')); + await user.type(input, 'TWO', { skipClick: true }); + + // Complete 3-across + await user.type(input, 'NO', { skipClick: true }); + + // Move to and complete 2-down + await user.type(input, 'E', { skipClick: true }); + + // Go back to 1-across + await user.click(getByLabelText('clue-1-across')); + + // Tape should bring us to the first cell of 3-across + fireEvent.keyDown(input, { key: 'Tab' }); + // Type X at the new position + fireEvent.keyDown(input, { key: 'X' }); + const { x, y } = posForText(getByText('X')); + + // Should be at start of 3-across in r2c3 + expect(x).toBe('20.125'); + expect(y).toBe('10.125'); + }); + + it('tab moves to first empty cell in partially filled across clue', async () => { + const { getByLabelText, getByText, user } = setup( + + ); + const input = getByLabelText('crossword-input'); + + // Start at 3-across and fill in N + await user.click(getByLabelText('clue-3-across')); + await user.type(input, 'N', { skipClick: true }); + + // Move to 1-across + await user.click(getByLabelText('clue-1-across')); + + // Tab should bring us to empty O in 3-across + fireEvent.keyDown(input, { key: 'Tab' }); + + // Type X at the new position to verify location + fireEvent.keyDown(input, { key: 'X' }); + const { x, y } = posForText(getByText('X')); + + // Should be at second cell of NO (3-across) + expect(x).toBe('30.125'); + expect(y).toBe('10.125'); + }); + + it('tab moves to first empty cell in partially filled down clue', async () => { + const { getByLabelText, getByText, user } = setup( + + ); + const input = getByLabelText('crossword-input'); + + // Start at 2-down and partially fill it (ON_) + await user.click(getByLabelText('clue-2-down')); + await user.type(input, 'O', { skipClick: true }); + await user.type(input, 'N', { skipClick: true }); + + // Go to 1-across + await user.click(getByLabelText('clue-1-across')); + // Tab twice to go to 3-across then 2-down + fireEvent.keyDown(input, { key: 'Tab' }); + fireEvent.keyDown(input, { key: 'Tab' }); + + // Type X at the new position to verify we're at the empty end of clue 2-down + fireEvent.keyDown(input, { key: 'X' }); + const { x, y } = posForText(getByText('X')); + + // Should be at middle cell of ONE + expect(x).toBe('20.125'); + expect(y).toBe('20.125'); + }); +});