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');
+ });
+});