Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion src/apps/chess/components/ChessTable.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
the squares past a tap. The pieces are sized from the board, not the
screen, so they always fit their squares. */
.board {
position: relative;
container-type: inline-size;
display: grid;
grid-template-columns: repeat(8, 1fr);
Expand Down Expand Up @@ -207,7 +208,6 @@
color: #ff6b5e;
}

.promotion,
.actions {
display: flex;
flex-wrap: wrap;
Expand Down Expand Up @@ -309,3 +309,59 @@
pointer-events: none;
cursor: grabbing;
}

/* The promotion picker, over the board: a scrim that dims it and takes the
tap that cancels, and the column of pieces down the promotion file, a
square each, from the far rank. */
.promotion {
position: absolute;
inset: 0;
z-index: 2;
}

.promotionScrim {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.45);
cursor: default;
-webkit-tap-highlight-color: transparent;
}

.promotionFile {
position: absolute;
top: 0;
z-index: 1;
width: 12.5%;
margin: 0;
padding: 0;
list-style: none;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.6);
}

.promotionPiece {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
padding: 0;
border: none;
border-radius: 50%;
background: radial-gradient(circle, #fff 20%, #b0b0b0 100%);
font-size: 9cqi;
line-height: 1;
cursor: pointer;
touch-action: manipulation;
transition: border-radius 0.1s, background 0.1s;
}

.promotionPiece:hover,
.promotionPiece:focus-visible {
border-radius: 0;
background: radial-gradient(circle, #f0c96a 20%, #c27a1a 100%);
outline: none;
}
94 changes: 53 additions & 41 deletions src/apps/chess/components/ChessTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
// resignation half made — each keyed to the view it was made against,
// so a new position drops it rather than acting on one that is gone.
const [picked, setPicked] = useState<{ view: ChessView; square: string } | null>(null)
const [promoting, setPromoting] = useState<{ view: ChessView; from: string; moves: string[] } | null>(null)
const [promoting, setPromoting] = useState<{ view: ChessView; from: string; to: string; moves: string[] } | null>(null)
const [confirmResign, setConfirmResign] = useState<ChessView | null>(null)
const [clockChoice, setClockChoice] = useState('3+2')

Expand All @@ -116,6 +116,13 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
const pendingPromotion = promoting?.view === view ? promoting : null
const targets = from === null ? [] : targetsFrom(view.legalMoves, from)
const board = readBoard(view.fen)
// A promotion asking shows its pawn already on the last rank, under the
// picker, as the board would once it is played.
const shown = new Map(board)
if (pendingPromotion !== null) {
shown.set(pendingPromotion.to, board.get(pendingPromotion.from) ?? '')
shown.delete(pendingPromotion.from)
}
const last = lastMoveSquares(view.moves)
// The king of the side to move — or, once mated, of the side that was.
const checkedKing = view.inCheck ? (view.fen?.split(' ')[1] === 'b' ? 'k' : 'K') : null
Expand All @@ -125,7 +132,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
const moves = movesTo(view.legalMoves, start, square)
setPicked(null)
if (moves.length === 1) table.play(moves[0])
else setPromoting({ view, from: start, moves })
else setPromoting({ view, from: start, to: square, moves })
}

const tap = (square: string) => {
Expand Down Expand Up @@ -286,7 +293,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
onPointerCancel={onPointerCancel}
>
{squares.map((square, index) => {
const piece = board.get(square)
const piece = shown.get(square)
const mine = piece !== undefined && colorOfPiece(piece) === myColor
// a1 dark: a square is dark where its file and rank index sum even.
const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0
Expand All @@ -307,6 +314,8 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
data-check={piece !== undefined && piece === checkedKing ? 'true' : undefined}
data-grab={grab ? 'true' : undefined}
data-dragging={ghost !== null && from === square ? 'true' : undefined}
// Under the picker's scrim, out of reach of the keyboard as of the pointer.
inert={pendingPromotion !== null}
onPointerDown={event => onPointerDown(event, square)}
onClick={() => tap(square)}
>
Expand All @@ -328,6 +337,47 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
</button>
)
})}
{/* Lichess's picker: the pieces stacked down the file from the
promotion square, which is always on the viewer's far rank,
over a dimmed board a tap on which lets the pawn go back. */}
{pendingPromotion !== null && (
<div
className={styles.promotion}
role="group"
aria-label="promote to"
onKeyDown={event => {
if (event.key === 'Escape') {
event.preventDefault()
cancelPromotion()
}
}}
>
<ul className={styles.promotionFile} style={{ left: `${(squares.indexOf(pendingPromotion.to) % 8) * 12.5}%` }}>
{PROMOTIONS.map(({ letter, name }, i) => {
const uci = pendingPromotion.moves.find(move => move.endsWith(letter))
if (uci === undefined) return null
return (
<li key={letter}>
<button
type="button"
className={`${styles.promotionPiece} ${myColor === 'white' ? styles.whitePiece : styles.blackPiece}`}
aria-label={name}
autoFocus={i === 0}
onClick={() => {
refocus.current = { square: uci.slice(2, 4) }
setPromoting(null)
table.play(uci)
}}
>
<span aria-hidden="true">{glyph(myColor === 'white' ? letter.toUpperCase() : letter)}</span>
</button>
</li>
)
})}
</ul>
<button type="button" className={styles.promotionScrim} aria-label="Cancel" onClick={cancelPromotion} />
</div>
)}
</div>
{me?.color !== undefined && <ClockRow view={view} seatId={me.playerId} color={me.color} you />}
{/* On the page, not the board: the board clips its overflow, and a
Expand All @@ -345,44 +395,6 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => {
document.body
)}

{pendingPromotion !== null && (
<div
className={styles.promotion}
role="group"
aria-label="promote to"
onKeyDown={event => {
if (event.key === 'Escape') {
event.preventDefault()
cancelPromotion()
}
}}
>
{PROMOTIONS.map(({ letter, name }, i) => {
const uci = pendingPromotion.moves.find(move => move.endsWith(letter))
if (uci === undefined) return null
return (
<button
key={letter}
type="button"
className={felt.secondary}
aria-label={name}
autoFocus={i === 0}
onClick={() => {
refocus.current = { square: uci.slice(2, 4) }
setPromoting(null)
table.play(uci)
}}
>
<span aria-hidden="true">{glyph(myColor === 'white' ? letter.toUpperCase() : letter)}</span> {name}
</button>
)
})}
<button type="button" className={felt.link} onClick={cancelPromotion}>
Cancel
</button>
</div>
)}

{view.phase === 'playing' && me !== undefined && (
<div className={styles.actions}>
{confirmResign === view ? (
Expand Down
47 changes: 47 additions & 0 deletions src/apps/chess/components/__tests__/ChessTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,53 @@ describe('ChessTable', () => {
expect(t.play).not.toHaveBeenCalled()
})

it('a promotion asking shows the pawn on the last rank, the picker over it, and puts it back on cancel', () => {
mountWith(view())
fireEvent.click(square('e7'))
fireEvent.click(square('e8'))
expect(square('e7')).toHaveAccessibleName('e7')
expect(square('e8')).toHaveAccessibleName('e8, white pawn')
const picker = screen.getByRole('group', { name: 'promote to' })
// on the board, down the e-file: the fifth column from the left
expect(screen.getByRole('group', { name: 'board' })).toContainElement(picker)
expect(within(picker).getByRole('list')).toHaveStyle({ left: '50%' })
expect(within(picker).getAllByRole('button').map(b => b.getAttribute('aria-label'))).toEqual(['Queen', 'Rook', 'Bishop', 'Knight', 'Cancel'])
fireEvent.click(within(picker).getByRole('button', { name: 'Cancel' }))
expect(square('e7')).toHaveAccessibleName('e7, white pawn')
expect(square('e8')).toHaveAccessibleName('e8')
})

it('a promotion asking takes the squares out of reach, and gives them back once answered', () => {
mountWith(view())
const cells = () => within(screen.getByRole('group', { name: 'board' })).getAllByRole('button', { name: /^[a-h][1-8]\b/ })
fireEvent.click(square('e7'))
expect(cells().filter(cell => cell.hasAttribute('inert'))).toHaveLength(0)
fireEvent.click(square('e8'))
expect(cells().filter(cell => cell.hasAttribute('inert'))).toHaveLength(64)
expect(within(screen.getByRole('group', { name: 'promote to' })).getByRole('button', { name: 'Queen' })).not.toHaveAttribute('inert')
fireEvent.keyDown(screen.getByRole('group', { name: 'promote to' }), { key: 'Escape' })
expect(cells().filter(cell => cell.hasAttribute('inert'))).toHaveLength(0)
expect(square('e7')).toHaveFocus()
})

it('for Black, the picker stands on the promotion file as Black sees it', () => {
// Black Pe2 against Kh1, bob on turn: e is the fourth column from Black's left.
mountWith(
view({
fen: '7k/8/8/8/8/8/4p3/7K b - - 0 1',
sideToMove: 'black',
currentPlayerId: 'bob',
legalMoves: ['e2e1b', 'e2e1n', 'e2e1q', 'e2e1r', 'h8g8']
}),
{},
'bob'
)
fireEvent.click(square('e2'))
fireEvent.click(square('e1'))
expect(square('e1')).toHaveAccessibleName('e1, black pawn')
expect(within(screen.getByRole('group', { name: 'promote to' })).getByRole('list')).toHaveStyle({ left: '37.5%' })
})

// A move made on the board instead abandons the promotion: the picker
// must not linger to send a second move for the same turn.
it('another move on the board drops a pending promotion', () => {
Expand Down
Loading