From 6199e0ef2689304d33d2fe576f4ab3f2788703b5 Mon Sep 17 00:00:00 2001 From: Andrei Miron Date: Wed, 12 Aug 2026 16:18:24 +0300 Subject: [PATCH 1/2] feat(placement-ordering): add Tab/Shift+Tab keyboard accessibility support PIE-767 --- __mocks__/@dnd-kit/core.js | 16 ++ .../__tests__/keyboard-coordinates.test.js | 161 ++++++++++++++++++ .../src/__tests__/ordering.test.js | 17 ++ .../placement-ordering-keyboard.test.jsx | 108 ++++++++++++ .../src/keyboard-coordinates.js | 122 +++++++++++++ .../src/placement-ordering.jsx | 28 ++- 6 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 packages/placement-ordering/src/__tests__/keyboard-coordinates.test.js create mode 100644 packages/placement-ordering/src/__tests__/placement-ordering-keyboard.test.jsx create mode 100644 packages/placement-ordering/src/keyboard-coordinates.js diff --git a/__mocks__/@dnd-kit/core.js b/__mocks__/@dnd-kit/core.js index 532eecbfd5..223bb87a8d 100644 --- a/__mocks__/@dnd-kit/core.js +++ b/__mocks__/@dnd-kit/core.js @@ -41,3 +41,19 @@ export const KeyboardCode = { Enter: 'Enter', Tab: 'Tab', }; + +// Mirrors dnd-kit's own defaultKeyboardCoordinateGetter (25px step per arrow key). +export const defaultKeyboardCoordinateGetter = (event, { currentCoordinates }) => { + switch (event.code) { + case KeyboardCode.Right: + return { ...currentCoordinates, x: currentCoordinates.x + 25 }; + case KeyboardCode.Left: + return { ...currentCoordinates, x: currentCoordinates.x - 25 }; + case KeyboardCode.Down: + return { ...currentCoordinates, y: currentCoordinates.y + 25 }; + case KeyboardCode.Up: + return { ...currentCoordinates, y: currentCoordinates.y - 25 }; + default: + return undefined; + } +}; diff --git a/packages/placement-ordering/src/__tests__/keyboard-coordinates.test.js b/packages/placement-ordering/src/__tests__/keyboard-coordinates.test.js new file mode 100644 index 0000000000..2e3e0b3417 --- /dev/null +++ b/packages/placement-ordering/src/__tests__/keyboard-coordinates.test.js @@ -0,0 +1,161 @@ +import { defaultKeyboardCoordinateGetter, KeyboardCode } from '@dnd-kit/core'; +import { closestDroppableKeyboardCoordinates } from '../keyboard-coordinates'; + +// Two target tiles stacked vertically, plus an empty gap left behind in the choices row. +const target1Rect = { left: 0, top: 100, width: 200, height: 40, right: 200, bottom: 140 }; +const target2Rect = { left: 0, top: 150, width: 200, height: 40, right: 200, bottom: 190 }; +const choiceGapRect = { left: 0, top: 0, width: 200, height: 40, right: 200, bottom: 40 }; + +function buildContext({ collisionRect, extraContainers = {} }) { + const droppableRects = new Map([ + ['drop-target-c1-2-instance', target1Rect], + ['drop-target-c2-3-instance', target2Rect], + ['drop-choice-c3-0-instance', choiceGapRect], + ]); + const droppableContainers = new Map([ + ['drop-target-c1-2-instance', { disabled: false, data: { current: { id: 'c1', type: 'target' } } }], + ['drop-target-c2-3-instance', { disabled: false, data: { current: { id: 'c2', type: 'target' } } }], + ['drop-choice-c3-0-instance', { disabled: false, data: { current: { id: 'c3', type: 'choice' } } }], + ...Object.entries(extraContainers), + ]); + + return { droppableRects, droppableContainers, collisionRect }; +} + +const activeChoice = { data: { current: { id: 'c4', type: 'choice' } } }; +const activeTarget = { data: { current: { id: 'c5', type: 'target' } } }; + +function makeEvent(code, shiftKey = false) { + return { code, preventDefault: jest.fn(), shiftKey }; +} + +describe('closestDroppableKeyboardCoordinates', () => { + describe('arrow keys', () => { + it('delegates to dnd-kit default keyboard coordinate getter, unchanged', () => { + const collisionRect = { left: 0, top: 100, width: 200, height: 40 }; + const context = buildContext({ collisionRect }); + const currentCoordinates = { x: 10, y: 20 }; + + [KeyboardCode.Down, KeyboardCode.Up, KeyboardCode.Left, KeyboardCode.Right].forEach((code) => { + const event = makeEvent(code); + const result = closestDroppableKeyboardCoordinates(event, { context, currentCoordinates }); + const expected = defaultKeyboardCoordinateGetter(event, { context, currentCoordinates }); + + expect(result).toEqual(expected); + }); + }); + + it('does not call preventDefault for arrow keys (matches existing dnd-kit behavior)', () => { + const collisionRect = { left: 0, top: 100, width: 200, height: 40 }; + const context = buildContext({ collisionRect }); + const event = makeEvent(KeyboardCode.Down); + + closestDroppableKeyboardCoordinates(event, { context, currentCoordinates: { x: 0, y: 0 } }); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('Tab / Shift+Tab', () => { + it('jumps to the next droppable, placing the dragged item\'s top-left at its center-left', () => { + const collisionRect = { left: 0, top: 0, width: 200, height: 40 }; + const context = buildContext({ collisionRect }); + // Currently positioned over the choice gap (topmost by y). + const currentCoordinates = { x: choiceGapRect.left, y: choiceGapRect.top }; + + const next = closestDroppableKeyboardCoordinates(makeEvent('Tab'), { context, currentCoordinates }); + + expect(next).toEqual({ x: target1Rect.left, y: target1Rect.top + target1Rect.height / 2 }); + }); + + it('cycles backwards with Shift+Tab', () => { + const collisionRect = { left: 0, top: 100, width: 200, height: 40 }; + const context = buildContext({ collisionRect }); + const currentCoordinates = { x: target1Rect.left, y: target1Rect.top }; + + const next = closestDroppableKeyboardCoordinates(makeEvent('Tab', true), { context, currentCoordinates }); + + expect(next).toEqual({ x: choiceGapRect.left, y: choiceGapRect.top + choiceGapRect.height / 2 }); + }); + + it('calls preventDefault so native Tab focus movement does not also happen', () => { + const collisionRect = { left: 0, top: 100, width: 200, height: 40 }; + const context = buildContext({ collisionRect }); + const event = makeEvent('Tab'); + + closestDroppableKeyboardCoordinates(event, { context, currentCoordinates: { x: 0, y: 100 } }); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('excludes the dragged tile\'s own droppable (already disabled while isDragging) from the candidates', () => { + // tile.jsx disables a tile's own droppable via `useDroppable({ disabled: isDragging })`, + // so the dragged target's own slot must not appear as an enabled candidate here. + const collisionRect = { left: 0, top: 150, width: 200, height: 40 }; + const context = buildContext({ + collisionRect, + extraContainers: { 'drop-target-c2-3-instance': { disabled: true } }, + }); + const currentCoordinates = { x: target2Rect.left, y: target2Rect.top }; + + const next = closestDroppableKeyboardCoordinates(makeEvent('Tab'), { context, currentCoordinates }); + + // With target2 (its own slot) excluded, the remaining candidates are target1 and + // the choice gap. target1 is closest to the current position, so it becomes the + // "current" index, and Tab steps forward from there to the choice gap. + expect(next).toEqual({ x: choiceGapRect.left, y: choiceGapRect.top + choiceGapRect.height / 2 }); + }); + + it('lands on an occupied target tile just like any other enabled droppable', () => { + // Occupied vs. empty target tiles are both just "enabled droppables" from this + // getter's point of view — the swap/replacement behavior itself lives in the + // reducer (ordering.js), triggered the same way as pointer dragging once the + // item is placed there. + const collisionRect = { left: 0, top: 0, width: 200, height: 40 }; + const context = buildContext({ collisionRect }); + const currentCoordinates = { x: choiceGapRect.left, y: choiceGapRect.top }; + + const next = closestDroppableKeyboardCoordinates(makeEvent('Tab'), { + active: activeTarget, + context, + currentCoordinates, + }); + + expect(next).toEqual({ x: target1Rect.left, y: target1Rect.top + target1Rect.height / 2 }); + }); + + it('excludes other choice-row tiles while dragging a choice, cycling only through targets', () => { + // Dragging a choice: the choice gap must not be a Tab stop, since dropping a + // choice onto another choice is a no-op in the reducer. With the choice gap + // excluded, only target1/target2 remain, so Shift+Tab (backwards) from target1 + // wraps around to target2 instead of landing on the choice gap. + const collisionRect = { left: 0, top: 100, width: 200, height: 40 }; + const context = buildContext({ collisionRect }); + const currentCoordinates = { x: target1Rect.left, y: target1Rect.top }; + + const next = closestDroppableKeyboardCoordinates(makeEvent('Tab', true), { + active: activeChoice, + context, + currentCoordinates, + }); + + expect(next).toEqual({ x: target2Rect.left, y: target2Rect.top + target2Rect.height / 2 }); + }); + + it('still allows returning a placed target back onto a choice-row gap', () => { + // Dragging a target (a placed choice): the choice-row gap is a valid "return to + // pool" destination and must remain a Tab stop. + const collisionRect = { left: 0, top: 100, width: 200, height: 40 }; + const context = buildContext({ collisionRect }); + const currentCoordinates = { x: target1Rect.left, y: target1Rect.top }; + + const next = closestDroppableKeyboardCoordinates(makeEvent('Tab', true), { + active: activeTarget, + context, + currentCoordinates, + }); + + expect(next).toEqual({ x: choiceGapRect.left, y: choiceGapRect.top + choiceGapRect.height / 2 }); + }); + }); +}); diff --git a/packages/placement-ordering/src/__tests__/ordering.test.js b/packages/placement-ordering/src/__tests__/ordering.test.js index 0e7cd4c8bd..b4a17ee5e4 100644 --- a/packages/placement-ordering/src/__tests__/ordering.test.js +++ b/packages/placement-ordering/src/__tests__/ordering.test.js @@ -158,4 +158,21 @@ describe('ordering', () => { } }); }); + + describe('placing into an already-occupied target', () => { + // Whether the "move" action originates from pointer dragging, arrow-key dragging, + // or Tab-based keyboard placement, it all funnels through this same reducer path — + // placing onto an occupied target swaps the two targets' contents. + it('swaps the two targets instead of overwriting', () => { + const choices = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const state = buildState(choices, [1, 2, undefined], [], { includeTargets: true, allowSameChoiceInTargets: true }); + + const from = { id: 1, type: 'target', index: 0 }; + const to = { id: 2, type: 'target', index: 1 }; + + const update = reducer({ type: 'move', from, to }, state); + + expect(update.response).toEqual([2, 1, undefined]); + }); + }); }); diff --git a/packages/placement-ordering/src/__tests__/placement-ordering-keyboard.test.jsx b/packages/placement-ordering/src/__tests__/placement-ordering-keyboard.test.jsx new file mode 100644 index 0000000000..2750bf0c20 --- /dev/null +++ b/packages/placement-ordering/src/__tests__/placement-ordering-keyboard.test.jsx @@ -0,0 +1,108 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { PlacementOrdering } from '../placement-ordering'; +import { closestDroppableKeyboardCoordinates } from '../keyboard-coordinates'; + +jest.mock('../ordering', () => ({ + buildState: jest.fn().mockReturnValue({ tiles: [], choices: [], response: [] }), + reducer: jest.fn().mockReturnValue({ tiles: [], choices: [], response: [] }), +})); + +const mockDragProvider = jest.fn((props) =>
{props.children}
); + +jest.mock('@pie-lib/drag', () => ({ + DragProvider: (props) => mockDragProvider(props), +})); + +describe('PlacementOrdering keyboard placement wiring', () => { + const choices = [{ id: 'c1', label: 'C1' }, { id: 'c2', label: 'C2' }]; + + const renderWithConfig = (config) => { + mockDragProvider.mockClear(); + + render( + , + ); + + // React (in dev builds) calls a brand-new function component type an extra time, + // with no arguments, the very first time that type is ever rendered in the process + // (to capture a stack-trace frame for warnings) — harmless, but it means the *last* + // recorded call isn't reliably the real one. Use the last call that actually + // received props instead. + const callsWithProps = mockDragProvider.mock.calls.filter((call) => call[0] !== undefined); + + return callsWithProps[callsWithProps.length - 1][0]; + }; + + // "placementArea" is normalized by the controller into model.config.includeTargets + // before it reaches this component, so gating on includeTargets here is equivalent + // to gating on placementArea. + describe('when placementArea is true (includeTargets: true)', () => { + it('passes the Tab-based coordinateGetter to DragProvider', () => { + const props = renderWithConfig({ includeTargets: true, orientation: 'vertical' }); + + expect(props.keyboardCoordinateGetter).toBe(closestDroppableKeyboardCoordinates); + }); + + it('configures keyboardCodes so Tab no longer ends the drag, while Space/Enter/Escape are preserved', () => { + const props = renderWithConfig({ includeTargets: true, orientation: 'vertical' }); + + expect(props.keyboardCodes).toEqual({ + start: ['Space', 'Enter'], + cancel: ['Escape'], + end: ['Space', 'Enter'], + }); + expect(props.keyboardCodes.end).not.toContain('Tab'); + }); + + it('passes screen reader instructions describing Tab/Shift+Tab placement', () => { + const props = renderWithConfig({ includeTargets: true, orientation: 'vertical' }); + + expect(props.accessibility.screenReaderInstructions.draggable).toEqual( + 'Press Space or Enter to pick up this answer choice. Once picked up, use Tab or Shift+Tab to cycle through response areas, or use arrow keys to move it freely. Press Space or Enter to drop, or Escape to cancel.', + ); + }); + }); + + describe('when placementArea is false (includeTargets: false)', () => { + it('does not pass a custom keyboardCoordinateGetter', () => { + const props = renderWithConfig({ includeTargets: false, orientation: 'vertical' }); + + expect(props.keyboardCoordinateGetter).toBeUndefined(); + }); + + it('does not pass custom keyboardCodes, leaving dnd-kit defaults (including Tab-ends-drag) untouched', () => { + const props = renderWithConfig({ includeTargets: false, orientation: 'vertical' }); + + expect(props.keyboardCodes).toBeUndefined(); + }); + + it('does not pass accessibility instructions describing Tab-based placement', () => { + const props = renderWithConfig({ includeTargets: false, orientation: 'vertical' }); + + expect(props.accessibility).toBeUndefined(); + }); + }); + + describe('when placementArea is missing entirely', () => { + it('does not pass a custom keyboardCoordinateGetter or keyboardCodes', () => { + const props = renderWithConfig({ orientation: 'vertical' }); + + expect(props.keyboardCoordinateGetter).toBeUndefined(); + expect(props.keyboardCodes).toBeUndefined(); + }); + }); + + describe('existing behavior preserved regardless of placementArea', () => { + it('still passes onDragEnd and collisionDetection to DragProvider', () => { + const props = renderWithConfig({ includeTargets: true, orientation: 'vertical' }); + + expect(typeof props.onDragEnd).toBe('function'); + expect(typeof props.collisionDetection).toBe('function'); + }); + }); +}); diff --git a/packages/placement-ordering/src/keyboard-coordinates.js b/packages/placement-ordering/src/keyboard-coordinates.js new file mode 100644 index 0000000000..1cf40f9444 --- /dev/null +++ b/packages/placement-ordering/src/keyboard-coordinates.js @@ -0,0 +1,122 @@ +import { defaultKeyboardCoordinateGetter, KeyboardCode } from '@dnd-kit/core'; + +/** + * Custom keyboard coordinate getter for placement-ordering's Tab-based placement mode + * (enabled only when the item is configured with `placementArea` / `includeTargets`). + * + * Tab/Shift+Tab cycle the dragged choice directly onto the next/previous enabled + * droppable (a placement/target tile, or an empty gap left behind in the choices row), + * in real on-screen position order, placing the dragged item's own top-left corner at + * the target's center-left point. + * + * Arrow keys are delegated to dnd-kit's own `defaultKeyboardCoordinateGetter` so the + * pre-existing free-form arrow-key dragging behavior is left completely unchanged. + * + * The tile currently being dragged already disables its own droppable + * (see `useDroppable({ disabled: isDragging })` in tile.jsx), so it's naturally + * excluded from the candidate list here without any extra bookkeeping. + * + * When picking up a choice from the choices row, the other not-yet-placed choice + * tiles are also registered droppables (tile.jsx disables a droppable only while it's + * the one being dragged), but dropping a choice onto another choice is a no-op in the + * reducer (ordering.js's updateResponse has no `choice -> choice` case) — so they're + * excluded from the Tab/Shift+Tab cycle, leaving only the real placement targets. + */ +export const closestDroppableKeyboardCoordinates = (event, { active, context, currentCoordinates }) => { + const { code } = event; + const isTab = code === 'Tab'; + const isArrow = + code === KeyboardCode.Down || code === KeyboardCode.Up || code === KeyboardCode.Left || code === KeyboardCode.Right; + + if (!isTab && !isArrow) { + return undefined; + } + + if (isArrow) { + return defaultKeyboardCoordinateGetter(event, { context, currentCoordinates }); + } + + event.preventDefault(); + + const { droppableRects, droppableContainers, collisionRect } = context; + + if (!droppableRects || droppableRects.size === 0) { + return currentCoordinates; + } + + // `currentCoordinates` is the top-left of the dragged item's collision rect (not its + // center), so derive the dragged item's center in the same frame before comparing it + // against droppable centers below. + const draggedHalfSize = { + x: (collisionRect?.width || 0) / 2, + y: (collisionRect?.height || 0) / 2, + }; + const currentCenter = { + x: currentCoordinates.x + draggedHalfSize.x, + y: currentCoordinates.y + draggedHalfSize.y, + }; + + // Only exclude other choice tiles while dragging a choice; dragging a placed target + // back onto a choice-row gap is still a valid "return to pool" destination. + const activeType = active?.data?.current?.type; + const excludeChoiceDroppables = activeType === 'choice'; + + const targets = []; + + for (const [id, container] of droppableContainers) { + if (container?.disabled) continue; + + if (excludeChoiceDroppables && container?.data?.current?.type === 'choice') continue; + + const rect = droppableRects.get(id); + + if (!rect) continue; + + const center = { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + // Land the dragged item's own top-left corner at the target's center-left point, + // rather than at the target's own top-left corner. + const dropPosition = { + x: rect.left, + y: rect.top + rect.height / 2, + }; + + targets.push({ id, dropPosition, center }); + } + + if (targets.length === 0) { + return currentCoordinates; + } + + const reverse = event.shiftKey; + + // Sort targets by real on-screen position (top to bottom, then left to right), so + // this works the same whether the tiler is laid out vertically or horizontally. + targets.sort((a, b) => { + if (Math.abs(a.center.y - b.center.y) > 10) return a.center.y - b.center.y; + return a.center.x - b.center.x; + }); + + // Find the current target (closest to current coordinates) + let currentIndex = 0; + let minDist = Infinity; + + for (let i = 0; i < targets.length; i++) { + const dist = distance(currentCenter, targets[i].center); + + if (dist < minDist) { + minDist = dist; + currentIndex = i; + } + } + + const nextIndex = reverse + ? (currentIndex - 1 + targets.length) % targets.length + : (currentIndex + 1) % targets.length; + + return targets[nextIndex].dropPosition; +}; + +const distance = (a, b) => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); diff --git a/packages/placement-ordering/src/placement-ordering.jsx b/packages/placement-ordering/src/placement-ordering.jsx index 0e4777f6cc..94e2702995 100644 --- a/packages/placement-ordering/src/placement-ordering.jsx +++ b/packages/placement-ordering/src/placement-ordering.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import debug from 'debug'; import { difference, isEqual, uniqueId } from 'lodash-es'; import { styled } from '@mui/material/styles'; -import { closestCenter } from '@dnd-kit/core'; +import { rectIntersection } from '@dnd-kit/core'; import { Collapsible, color, Feedback, hasMedia, hasText, PreviewPrompt, UiLayout } from '@pie-lib/render-ui'; import { renderMath } from '@pie-lib/math-rendering'; @@ -15,6 +15,25 @@ import { DragProvider } from '@pie-lib/drag'; import { HorizontalTiler, VerticalTiler } from './tiler'; import { buildState, reducer } from './ordering'; import { haveSameValuesButDifferentOrder } from './utils'; +import { closestDroppableKeyboardCoordinates } from './keyboard-coordinates'; + +const getKeyboardDragOptions = (includeTargets) => + includeTargets + ? { + keyboardCoordinateGetter: closestDroppableKeyboardCoordinates, + keyboardCodes: { + start: ['Space', 'Enter'], + cancel: ['Escape'], + end: ['Space', 'Enter'], + }, + accessibility: { + screenReaderInstructions: { + draggable: + 'Press Space or Enter to pick up this answer choice. Once picked up, use Tab or Shift+Tab to cycle through response areas, or use arrow keys to move it freely. Press Space or Enter to drop, or Escape to cancel.', + }, + }, + } + : {}; const { translator } = Translator; @@ -324,7 +343,12 @@ export class PlacementOrdering extends React.Component { }; return ( - { }} onDragEnd={this.onDragEnd} collisionDetection={closestCenter}> + {}} + onDragEnd={this.onDragEnd} + collisionDetection={rectIntersection} + {...getKeyboardDragOptions(includeTargets)} + > {showTeacherInstructions && ( From e25493d53a2f670f185267733d9a66c1dd584524 Mon Sep 17 00:00:00 2001 From: CarlaCostea <56835388+CarlaCostea@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:13:19 +0300 Subject: [PATCH 2/2] Change collision detection method in DragProvider --- packages/placement-ordering/src/placement-ordering.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/placement-ordering/src/placement-ordering.jsx b/packages/placement-ordering/src/placement-ordering.jsx index 836e6d3849..6529bba328 100644 --- a/packages/placement-ordering/src/placement-ordering.jsx +++ b/packages/placement-ordering/src/placement-ordering.jsx @@ -368,7 +368,7 @@ export class PlacementOrdering extends React.Component { { }} onDragEnd={this.onDragEnd} - collisionDetection={closestCenter} + collisionDetection={rectIntersection} modifiers={[restrictToParentElement]} {...getKeyboardDragOptions(includeTargets)} >