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
11 changes: 11 additions & 0 deletions __mocks__/@dnd-kit/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,14 @@ export const closestCorners = jest.fn();
export const getFirstCollision = jest.fn();
export const pointerWithin = jest.fn();
export const rectIntersectionAlgorithm = jest.fn();

export const KeyboardCode = {
Space: 'Space',
Down: 'ArrowDown',
Right: 'ArrowRight',
Left: 'ArrowLeft',
Up: 'ArrowUp',
Esc: 'Escape',
Enter: 'Enter',
Tab: 'Tab',
};
115 changes: 115 additions & 0 deletions packages/match-list/src/__tests__/keyboard-coordinates.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { KeyboardCode } from '@dnd-kit/core';
import { closestDroppableKeyboardCoordinates } from '../keyboard-coordinates';

// Response row ("drop-2") sits directly above the choices pool.
const dropZoneRect = { left: 0, top: 100, width: 200, height: 40, right: 200, bottom: 140 };
const choicesPoolRect = { left: 0, top: 150, width: 400, height: 300, right: 400, bottom: 450 };

function buildContext({ collisionRect }) {
const droppableRects = new Map([
['drop-2', dropZoneRect],
['choices-pool', choicesPoolRect],
]);
const droppableContainers = new Map([
['drop-2', { disabled: false }],
['choices-pool', { disabled: false }],
]);

return { droppableRects, droppableContainers, collisionRect };
}

function makeEvent(code, shiftKey = false) {
return { code, preventDefault: () => {}, shiftKey };
}

describe('closestDroppableKeyboardCoordinates', () => {
describe('arrow keys', () => {
it('nudges the dragged item by a fixed step instead of jumping to a droppable', () => {
const collisionRect = { left: 0, top: 100, width: 200, height: 40 };
const context = buildContext({ collisionRect });
const currentCoordinates = { x: 10, y: 20 };

expect(
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Down), { context, currentCoordinates }),
).toEqual({ x: 10, y: 45 });
expect(
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Up), { context, currentCoordinates }),
).toEqual({ x: 10, y: -5 });
expect(
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Right), { context, currentCoordinates }),
).toEqual({ x: 35, y: 20 });
expect(
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Left), { context, currentCoordinates }),
).toEqual({ x: -15, y: 20 });
});
});

describe('Tab / Shift+Tab', () => {
it('jumps to the next droppable, placing the dragged item\'s top-left at the target\'s center-left', () => {
// Dragging an item currently positioned exactly over drop-2.
const collisionRect = { left: 0, top: 100, width: 200, height: 40 };
const context = buildContext({ collisionRect });
const currentCoordinates = { x: dropZoneRect.left, y: dropZoneRect.top };

const next = closestDroppableKeyboardCoordinates(makeEvent('Tab'), {
context,
currentCoordinates,
});

// x = choices-pool's left edge; y = choices-pool's vertical center. The dragged
// item's own top-left corner (not its center) lands there.
expect(next).toEqual({
x: choicesPoolRect.left,
y: choicesPoolRect.top + choicesPoolRect.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: dropZoneRect.left, y: dropZoneRect.top };

const next = closestDroppableKeyboardCoordinates(makeEvent('Tab', true), {
context,
currentCoordinates,
});

// Only one other target exists (choices-pool), so Shift+Tab wraps to it too.
expect(next).toEqual({
x: choicesPoolRect.left,
y: choicesPoolRect.top + choicesPoolRect.height / 2,
});
});

it('does not get stuck on its own drop-zone when cycling from a placed answer ("target")', () => {
// A "target" is simultaneously draggable and droppable for its own slot, so its own
// drop-zone rect can be off-by-a-few-px from the dragged node's own rect in a real
// browser (they're different DOM nodes). Simulate that mismatch here: the active
// item's own slot ("drop-2") is registered slightly below the item's actual
// collision rect, which — without excluding it — could make Tab cycle back onto it.
const collisionRect = { left: 0, top: 150, width: 200, height: 40 };
const droppableRects = new Map([
['drop-1', { left: 0, top: 100, width: 200, height: 40 }],
['drop-2', { left: 0, top: 152, width: 200, height: 42 }], // active item's own slot, slightly offset
['drop-3', { left: 0, top: 200, width: 200, height: 40 }],
['choices-pool', choicesPoolRect],
]);
const droppableContainers = new Map(
Array.from(droppableRects.keys(), (id) => [id, { disabled: false }]),
);
const context = { droppableRects, droppableContainers, collisionRect };
const currentCoordinates = { x: collisionRect.left, y: collisionRect.top };
const active = { id: 'target-9', data: { current: { type: 'target', id: 9, promptId: 2 } } };

const next = closestDroppableKeyboardCoordinates(makeEvent('Tab'), {
active,
context,
currentCoordinates,
});

// drop-3's center-left point (top:200, height:40 -> y:220), not the own
// slightly-lower drop-2.
expect(next).toEqual({ x: 0, y: 220 });
});
});
});
53 changes: 20 additions & 33 deletions packages/match-list/src/answer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ Holder.propTypes = {
const AnswerContentContainer = styled('div')(({ theme, isDragging, isOver, disabled, outcome }) => ({
color: color.text(),
backgroundColor: color.white(),
border: `1px solid ${outcome === 'correct' ? color.correct() :
outcome === 'incorrect' ? color.incorrect() :
theme.palette.grey[400]
}`,
border: `1px solid ${
outcome === 'correct' ? color.correct() : outcome === 'incorrect' ? color.incorrect() : theme.palette.grey[400]
}`,
cursor: disabled ? 'not-allowed' : 'pointer',
width: '100%',
padding: '10px',
Expand All @@ -55,20 +54,14 @@ const AnswerContentContainer = styled('div')(({ theme, isDragging, isOver, disab
transition: 'opacity 200ms linear',
wordBreak: 'break-word',
opacity: isDragging && !disabled ? 0.5 : isOver && !disabled ? 0.2 : 1,
touchAction: 'none'
touchAction: 'none',
}));

const AnswerContent = (props) => {
const { isDragging, isOver, title, disabled, empty, outcome, guideIndex, type } = props;

if (empty) {
return <Holder
index={guideIndex}
isOver={isOver}
disabled={disabled}
type={type}

/>;
return <Holder index={guideIndex} isOver={isOver} disabled={disabled} type={type} />;
} else {
return (
<AnswerContentContainer
Expand All @@ -91,9 +84,12 @@ const AnswerContainer = styled('div')(({ correct, theme }) => ({
padding: '0px',
textAlign: 'center',
height: 'initial',
border: correct === true ? `1px solid var(--feedback-correct-bg-color, ${color.correct()})` :
correct === false ? `1px solid var(--feedback-incorrect-bg-color, ${color.incorrect()})` :
'none',
border:
correct === true
? `1px solid var(--feedback-correct-bg-color, ${color.correct()})`
: correct === false
? `1px solid var(--feedback-incorrect-bg-color, ${color.incorrect()})`
: 'none',
}));

export class Answer extends React.Component {
Expand Down Expand Up @@ -130,16 +126,7 @@ export class Answer extends React.Component {
};

render() {
const {
id,
title,
isDragging = false,
className,
disabled,
isOver = false,
type,
correct,
} = this.props;
const { id, title, isDragging = false, className, disabled, isOver = false, type, correct } = this.props;

log('[render], props: ', this.props);

Expand Down Expand Up @@ -195,25 +182,25 @@ function DragAndDropAnswer(props) {
const isOver = droppable.isOver;

// compute style: apply transform to the element that actually moves
const transformStyle = transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined;
const transformStyle = transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined;

// If this item is a drop-zone (prompt slot), we render an outer droppable wrapper.
// For droppable wrapper we apply style to the outer wrapper
// The outer wrapper's rect is what dnd-kit measures for this slot's own droppable
// ("drop-{promptId}"), so it must stay untransformed — applying the drag transform
// there would make the slot's own droppable rect chase the dragged item during the
// drag, corrupting collision/keyboard-navigation results. The transform belongs on
// the inner draggable node instead.
if (dropId) {
return (
<div
ref={setDropRef}
style={{
flex: 1,
transform: transformStyle,
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isOver ? 'rgba(0,0,0,0.05)' : 'transparent',
backgroundColor: isDragging || isOver ? 'rgba(0,0,0,0.05)' : 'transparent',
}}
>
<div ref={setDragRef} {...listeners} {...attributes}>
<div ref={setDragRef} {...listeners} {...attributes} style={{ transform: transformStyle, transition }}>
<Answer {...props} isDragging={isDragging} isOver={isOver} />
</div>
</div>
Expand Down
137 changes: 137 additions & 0 deletions packages/match-list/src/keyboard-coordinates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { KeyboardCode } from '@dnd-kit/core';

const ARROW_STEP = 25;

/**
* Custom keyboard coordinate getter for non-sortable drag-and-drop.
*
* Tab/Shift+Tab cycle the dragged item directly onto the next/previous droppable
* (response area or the choices pool), in DOM order, placing the dragged item's own
* top-left corner at the target's center-left point. Arrow keys are left doing plain
* free-form movement (nudging the dragged item by a fixed step, same as dnd-kit's own
* default keyboard behavior) rather than jumping between droppables — collision
* detection (rectIntersection) still picks up whatever the item ends up actually
* overlapping.
*/
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;
}

event.preventDefault();

if (isArrow) {
switch (code) {
case KeyboardCode.Down:
return { ...currentCoordinates, y: currentCoordinates.y + ARROW_STEP };

case KeyboardCode.Up:
return { ...currentCoordinates, y: currentCoordinates.y - ARROW_STEP };

case KeyboardCode.Right:
return { ...currentCoordinates, x: currentCoordinates.x + ARROW_STEP };

case KeyboardCode.Left:
return { ...currentCoordinates, x: currentCoordinates.x - ARROW_STEP };

default:
return currentCoordinates;
}
}

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. Returning a droppable's *center* as the next
// coordinates (as opposed to its top-left) would shift the dragged item's top-left to
// that center, overshooting the target by roughly half its size and causing
// dnd-kit to resolve collisions against a neighboring droppable instead.
const draggedHalfSize = {
x: (collisionRect?.width || 0) / 2,
y: (collisionRect?.height || 0) / 2,
};
const currentCenter = {
x: currentCoordinates.x + draggedHalfSize.x,
y: currentCoordinates.y + draggedHalfSize.y,
};

// A placed answer ("target") is itself a droppable for its own prompt slot
// ("drop-{promptId}"). That self drop-zone must never be treated as a navigable
// target: it sits under the dragged item, so a tiny (even sub-pixel) discrepancy
// between its measured rect and the dragged item's own rect is enough to make it
// register as the "closest" candidate, which reads as the drag being stuck.
// Exclude it outright rather than relying on a distance threshold.
const activeData = active?.data?.current;
const ownDropId = activeData?.promptId != null ? `drop-${activeData.promptId}` : undefined;

// Collect top-left and center of all enabled droppable containers
const targets = [];

for (const [id, container] of droppableContainers) {
if (container?.disabled) continue;

if (id === ownDropId) 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;
}

// Tab/Shift+Tab: cycle through targets in DOM order (sorted top-to-bottom, left-to-right)
const reverse = event.shiftKey;

// Sort targets by position (top to bottom, then left to right)
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;
}
}

// Move to next/previous
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);
Loading
Loading