Skip to content

Commit

Permalink
Better keyboard controls
Browse files Browse the repository at this point in the history
  • Loading branch information
icecream17 committed Oct 23, 2021
1 parent 65cc881 commit 4c40195
Show file tree
Hide file tree
Showing 10 changed files with 169 additions and 47 deletions.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ Note: Many earlier versions are not specified, that's too much work.

When a `@types` dependency updates, they almost always don't affect anything.

## v0.26.4

- (use, a11y) `Ctrl+Home` goes to the first cell and `Ctrl+End` goes to the last cell
- (use, a11y) `Alt+{Remove}` now resets cells too (previously only `Shift+{Remove}`)
- (bug, use, a11y) Use event.key instead of event.code
- (a11y) Set aria-labelledby and aria-controls
- (a11y) Set sudoku's role to grid

## v0.26.3

- (spd) Only `Array.from(stuff).sort` when needed
- (spd) Make wing strategies skip lines with only 1 of such candidate
- (spd) Bind or move functions where they won't be redeclared all the time
- (code) Simplify keyboard code
- (code) Change type of Cell#state.candidateClasses
- (code) More linting and ternary
- (code) Make typescript allow `Set#has`, `Set#delete`, and `Map#has` to be called by any type

## v0.26.2

- (code) Speed up wing strategies and use 1 function for all of them. Saves like 0.5 kb.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "solver",
"version": "0.26.3",
"version": "0.26.4",
"private": true,
"homepage": "https://icecream17.github.io/solver",
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion src/Elems/Aside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default class Aside extends React.Component<AsideProps, AsideState> {
return (
<section className="App-aside">
<Tabs whenTabChange={this.whenTabChange} tabNames={tabNames} />
<div role="tabpanel" id="TabContent" tabIndex={-1}>{content}</div>
<div role="tabpanel" id="TabContent" aria-labelledby={`Tab${this.state.selectedTab}`} tabIndex={-1}>{content}</div>
</section>
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/Elems/AsideElems/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ export default class Tab extends React.Component<TabProps> {
const className = this.props.selected ? `Tab selected` : `Tab unselected`
return (
<Control
id={`Tab${this.props.index}`}
onClick={this.callbackIfNotSelected}
onFocus={this.props.whenFocused}
className={className}
role="tab"
innerRef={this.setSelfElement}
aria-controls={this.props.selected ? 'TabContent' : undefined}
aria-selected={this.props.selected}
>{this.props.title}</Control>
)
Expand Down
66 changes: 66 additions & 0 deletions src/Elems/AsideElems/Tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* a11y conformance tests
* https://w3c.github.io/aria-practices/#keyboard-interaction-21
*/

import { render, screen } from '@testing-library/react';
import Aside from "../Aside";
import Sudoku from '../../Api/Spaces/Sudoku';
import userEvent from '@testing-library/user-event';
import { switchTab } from '../../testUtils';

beforeEach(() => {
render(
<div>
<textarea data-testid="dummy-focusable"></textarea>
<Aside sudoku={new Sudoku()} />
</div>
);
})

test('Tab: When focus moves into the tab list, focus on the active tab element', () => {
const dummyTextarea = screen.getByTestId("dummy-focusable")
const stratsTab = screen.getByRole("tab", { name: "strats" })
userEvent.click(stratsTab)
dummyTextarea.focus()
userEvent.tab()
expect(stratsTab).toHaveFocus()
})

// Note: Saved by luck; tab doesn't actually check if the first meaningful element is focusable
// Note: I'm not sure if there's somewhat wrong with tab. TODO
test.skip('Tab: When in the tab list, focus on the tabpanel unless the first meaningful element is focusable', () => {
switchTab('strats')
userEvent.tab()
expect(screen.getByRole("button", { name: "clear" })).toHaveFocus()
})

test.skip('Left / Right arrow keys', () => {
const solveToolsTab = screen.getByRole("tab", { name: "solving tools" })
const stratsTab = screen.getByRole("tab", { name: "strats" })

userEvent.click(stratsTab)
userEvent.keyboard('{ArrowLeft}')
expect(solveToolsTab).toHaveFocus()
userEvent.keyboard('{ArrowLeft}')
expect(stratsTab).toHaveFocus()
userEvent.keyboard('{ArrowRight}')
expect(solveToolsTab).toHaveFocus()
userEvent.keyboard('{ArrowRight}')
expect(stratsTab).toHaveFocus()
})

// Space or Enter --> Activates tab if not activated automatically on focus
// Shift+F10 --> If the tab has an associated popup menu, opens the menu
// Delete --> If deletion is allowed....

test('Home / End', () => {
const solveToolsTab = screen.getByRole("tab", { name: "solving tools" })
const stratsTab = screen.getByRole("tab", { name: "strats" })

userEvent.click(stratsTab)
userEvent.keyboard('{Home}')
expect(solveToolsTab).toHaveFocus()
userEvent.keyboard('{End}')
expect(stratsTab).toHaveFocus()
})
10 changes: 5 additions & 5 deletions src/Elems/AsideElems/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ export default class Tabs extends React.Component<TabsProps, TabsState> {
/** Keyboard support */

whenKeyDown (event: React.KeyboardEvent) {
if (importantKeys.has(event.code)) { // @ts-expect-error Can't narrow
this.keysPressed.add(event.code)
if (importantKeys.has(event.key)) { // @ts-expect-error Can't narrow
this.keysPressed.add(event.key)
}

const movements = new Set<keyof typeof oppositeKeys>()
Expand Down Expand Up @@ -139,7 +139,7 @@ export default class Tabs extends React.Component<TabsProps, TabsState> {
}
throw new TypeError(`${[...movements].join(', ')} is invalid`)
}, () => {
this.props.whenTabChange(this.state.selectedTab)
this.props.whenTabChange(this.state.selectedTab) // Tab didn't necessarily change
if (tab) {
this.changeToMainContent()
}
Expand All @@ -153,9 +153,9 @@ export default class Tabs extends React.Component<TabsProps, TabsState> {
* when Tab & when Focus --> focus selected tab
*/
whenKeyUp (event: React.KeyboardEvent) {
this.keysPressed.delete(event.code)
this.keysPressed.delete(event.key)

if (event.code === "Tab") {
if (event.key === "Tab") {
this.tabTime = Date.now()
this.checkIfTabbedInto()
}
Expand Down
20 changes: 2 additions & 18 deletions src/Elems/MainElems/Cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,6 @@ import { IndexToNine, Mutable, SudokuDigits, ZeroToNine, _Callback } from '../..
const Candidates = React.lazy(() => import('./Candidates'));
const CandidatesDiff = React.lazy(() => import('./CandidatesDiff'));

// Maps keys to coords
export const keyboardMappings = {
'ArrowUp': { vRow: -1, vColumn: 0 },
'KeyW': { vRow: -1, vColumn: 0 },
'ArrowLeft': { vRow: 0, vColumn: -1 },
'KeyA': { vRow: 0, vColumn: -1 },
'ArrowDown': { vRow: 1, vColumn: 0 },
'KeyS': { vRow: 1, vColumn: 0 },
'ArrowRight': { vRow: 0, vColumn: 1 },
'KeyD': { vRow: 0, vColumn: 1 },
}

export type BaseCellProps = Readonly<{
row: IndexToNine
column: IndexToNine
Expand Down Expand Up @@ -395,7 +383,7 @@ export default class Cell extends React.Component<CellProps, CellState> {
const candidate = Number(event.key) as SudokuDigits
this.toggleCandidate(candidate)
} else if (['Backspace', 'Delete', 'Clear'].includes(event.key)) {
if (event.shiftKey) {
if (event.shiftKey || event.altKey) {
this.setState({
candidates: [1, 2, 3, 4, 5, 6, 7, 8, 9],
showCandidates: false,
Expand All @@ -407,14 +395,10 @@ export default class Cell extends React.Component<CellProps, CellState> {
error: true
})
}
} else if (event.key in keyboardMappings) {
// Keyboard controls
this.props.whenCellKeydown(this, event)
} else if (event.key === 'Escape') {
target.blur()
} else {
// If nothing happens, don't do anything
return;
this.props.whenCellKeydown(this, event) // keyboard controls
}

// Something happened, (see "pretend" docs above)
Expand Down
32 changes: 22 additions & 10 deletions src/Elems/MainElems/Sudoku.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import App from '../../App';
import { IndexToNine } from '../../Types';
import { getButtonCellElement, getTableCellElement } from './Sudoku.testUtils';

function tryKey (keyboard: string, row: IndexToNine, column: IndexToNine) {
userEvent.keyboard(keyboard)
expect(getButtonCellElement(row, column)).toHaveFocus()
}

beforeEach(() => {
render(<App />);
})

// The name === 'Sudoku' because aria-label === 'Sudoku'
// The role === 'grid', which is more accurate since the table is interactive
test('getting the sudoku table', () => {
expect(screen.getByRole('table', { name: 'Sudoku' })).toBeInTheDocument()
expect(screen.getByRole('grid', { name: 'Sudoku' })).toBeInTheDocument()
})

const cellTests = [
Expand Down Expand Up @@ -102,15 +108,17 @@ test("Resetting the candidates", () => {
// Since the cell has all candidates, and is blurred, textcontent = ''
expect(buttonCell).toHaveTextContent('')

// And now with the Alt key
userEvent.click(buttonCell)
userEvent.keyboard('{Shift>}{Backspace}{/Shift}')
userEvent.keyboard('123')
userEvent.keyboard('{Backspace}123')
userEvent.keyboard('{Alt>}{Backspace}{/Alt}')
userEvent.keyboard('123') // And now subtract 123
fireEvent.blur(buttonCell)

expect(buttonCell).toHaveTextContent('456789')
})

test("Cell keyboard navigation", () => {
test("Cell keyboard navigation: Arrows", () => {
const cornerCell = getButtonCellElement(0, 8)
userEvent.click(cornerCell)

Expand All @@ -119,12 +127,7 @@ test("Cell keyboard navigation", () => {
userEvent.tab()
expect(getButtonCellElement(1, 0)).toHaveFocus()

function tryKey(keyboard: string, row: IndexToNine, column: IndexToNine) {
userEvent.keyboard(keyboard)
expect(getButtonCellElement(row, column)).toHaveFocus()
}

tryKey('{ArrowLeft}', 1, 8)
tryKey('{ArrowLeft}', 1, 8) // Wraps around
tryKey('{ArrowLeft}', 1, 7)
tryKey('{ArrowUp}', 0, 7)
tryKey('{ArrowUp}', 8, 7)
Expand All @@ -133,3 +136,12 @@ test("Cell keyboard navigation", () => {
tryKey('{ArrowRight}', 0, 0)
})

test("Cell keyboard navigation: End / Home", () => {
const firstCell = getButtonCellElement(0, 0)
userEvent.click(firstCell)

tryKey('{End}', 0, 8) // End of row
tryKey('{Home}', 0, 0) // Start of row
tryKey('{Control>}{End}{/Control}', 8, 8) // Last cell
tryKey('{Control>}{Home}{/Control}', 0, 0) // First cell
})
62 changes: 51 additions & 11 deletions src/Elems/MainElems/Sudoku.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ import React from 'react';
import './Sudoku.css'
import Row from './Row';
import { IndexToNine, _Callback } from '../../Types';
import Cell, { keyboardMappings } from './Cell';
import Cell from './Cell';

const keyboardMappings = {
'ArrowUp': { vRow: -1, vColumn: 0 },
'KeyW': { vRow: -1, vColumn: 0 },
'ArrowLeft': { vRow: 0, vColumn: -1 },
'KeyA': { vRow: 0, vColumn: -1 },
'ArrowDown': { vRow: 1, vColumn: 0 },
'KeyS': { vRow: 1, vColumn: 0 },
'ArrowRight': { vRow: 0, vColumn: 1 },
'KeyD': { vRow: 0, vColumn: 1 },
}

export type BaseSudokuProps = Readonly<{
whenCellMounts: _Callback
Expand All @@ -19,6 +30,9 @@ type SudokuProps = BaseSudokuProps
* The main sudoku!!!
* The sudoku board state is sent back all the way to `App.js`
*
* Handles keyboard interactions.
* TODO: Handle selecting cells, including selecting multiple cells. (And set aria-selected and aria-multiselectable)
*
* @example
* // Sending state up
* <Sudoku whenUpdate={callback} />
Expand Down Expand Up @@ -50,7 +64,8 @@ export default class Sudoku extends React.Component<SudokuProps> {
}

return (
<table className='Sudoku' id='Sudoku' title='Sudoku' aria-label='Sudoku'>
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role --- https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/issues/817
<table className='Sudoku' id='Sudoku' title='Sudoku' aria-label='Sudoku' role='grid'>
<tbody ref={this.setTbodyElement}>
<Row {...getRepeatedProps()} />
<Row {...getRepeatedProps()} />
Expand All @@ -67,6 +82,7 @@ export default class Sudoku extends React.Component<SudokuProps> {
}

/**
* Implicitly blurs the previously focused cell
* INCOMPLETELY DOCUMENTED BUG: This focuses the element, but often the
*/
focusCell(row: IndexToNine, column: IndexToNine) {
Expand Down Expand Up @@ -97,18 +113,42 @@ export default class Sudoku extends React.Component<SudokuProps> {
return this.tbodyElement.children[index]
}

/**
* Keyboard controls as described by https://w3c.github.io/aria-practices/#grid
* TODO: https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_roving_tabindex
*/
whenCellKeydown(cell: Cell, event: React.KeyboardEvent) {
// Use default behavior when tabbing
if (event.key === 'Tab') {
return
}

event.preventDefault()

// TODO: Diagonal steps, use onkeyup and more state
const step = keyboardMappings[(event.key as keyof typeof keyboardMappings)];

// blur this and focus the other cell
(event.target as HTMLTableCellElement).blur()

this.focusCell(
(cell.props.row + 9 + step.vRow) % 9 as IndexToNine,
(cell.props.column + 9 + step.vColumn) % 9 as IndexToNine
)
if (event.key in keyboardMappings) {
const step = keyboardMappings[(event.key as keyof typeof keyboardMappings)];

this.focusCell(
(cell.props.row + 9 + step.vRow) % 9 as IndexToNine,
(cell.props.column + 9 + step.vColumn) % 9 as IndexToNine
)
} else if (event.key === 'Home') {
if (event.ctrlKey) {
this.focusCell(0, 0)
} else {
this.focusCell(cell.props.row, 0)
}
} else if (event.key === 'End') {
if (event.ctrlKey) {
this.focusCell(8, 8)
} else {
this.focusCell(cell.props.row, 8)
}
} else if (event.key === 'PageUp' && cell.props.row !== 0) {
this.focusCell(Math.max(cell.props.row - 3, 0) as IndexToNine, cell.props.column)
} else if (event.key === 'PageDown' && cell.props.row !== 8) {
this.focusCell(Math.min(cell.props.row + 3, 8) as IndexToNine, cell.props.column)
}
}
}
2 changes: 1 addition & 1 deletion src/Elems/Version.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ import StaticComponent from './StaticComponent';
*/
export default class Version extends StaticComponent {
render() {
return <span className="Version">v0.26.3</span>
return <span className="Version">v0.26.4</span>
}
}

0 comments on commit 4c40195

Please sign in to comment.