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
16 changes: 16 additions & 0 deletions __mocks__/@dnd-kit/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
};
161 changes: 161 additions & 0 deletions packages/placement-ordering/src/__tests__/keyboard-coordinates.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});
});
17 changes: 17 additions & 0 deletions packages/placement-ordering/src/__tests__/ordering.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
});
Original file line number Diff line number Diff line change
@@ -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) => <div>{props.children}</div>);

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(
<PlacementOrdering
model={{ config, choices }}
session={{ value: [] }}
onSessionChange={jest.fn()}
/>,
);

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