@pie-lib/mask-markup 3.0.14-next.35 → 3.0.14-next.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/choices/choice.js +24 -6
- package/lib/choices/choice.js.map +1 -1
- package/lib/choices/index.js +32 -9
- package/lib/choices/index.js.map +1 -1
- package/lib/components/blank.js +106 -29
- package/lib/components/blank.js.map +1 -1
- package/lib/drag-in-the-blank.js +139 -30
- package/lib/drag-in-the-blank.js.map +1 -1
- package/lib/keyboard-coordinates.js +156 -0
- package/lib/keyboard-coordinates.js.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/drag-in-the-blank.test.js +147 -2
- package/src/__tests__/keyboard-coordinates.test.js +202 -0
- package/src/choices/__tests__/index.test.js +104 -1
- package/src/choices/choice.jsx +16 -3
- package/src/choices/index.jsx +27 -3
- package/src/components/__tests__/blank.test.js +186 -5
- package/src/components/blank.jsx +108 -35
- package/src/drag-in-the-blank.jsx +132 -22
- package/src/keyboard-coordinates.js +147 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { render, screen, act } from '@testing-library/react';
|
|
2
|
+
import { render, screen, act, fireEvent } from '@testing-library/react';
|
|
3
3
|
import Blank from '../blank';
|
|
4
4
|
|
|
5
5
|
// Mock @dnd-kit hooks to avoid DndContext requirement
|
|
6
6
|
jest.mock('@dnd-kit/core', () => ({
|
|
7
|
-
useDraggable: jest.fn(() => ({
|
|
8
|
-
attributes: {},
|
|
7
|
+
useDraggable: jest.fn((options) => ({
|
|
8
|
+
attributes: options?.attributes || {},
|
|
9
9
|
listeners: {},
|
|
10
10
|
setNodeRef: jest.fn(),
|
|
11
11
|
transform: null,
|
|
@@ -163,8 +163,9 @@ describe('Blank', () => {
|
|
|
163
163
|
jest.runAllTimers();
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
const wrapper = container.firstChild; // StyledContent
|
|
167
|
-
const
|
|
166
|
+
const wrapper = container.firstChild; // StyledContent (outer droppable/click node)
|
|
167
|
+
const dragHandle = wrapper && wrapper.firstChild; // StyledDragHandle (inner draggable node)
|
|
168
|
+
const chip = dragHandle && dragHandle.firstChild; // StyledChip (rootRef)
|
|
168
169
|
|
|
169
170
|
// Width and height should include padding (24px) around measured content
|
|
170
171
|
expect(chip.style.width).toBe('129px');
|
|
@@ -229,4 +230,184 @@ describe('Blank', () => {
|
|
|
229
230
|
// Should show visual feedback for drag over
|
|
230
231
|
});
|
|
231
232
|
});
|
|
233
|
+
|
|
234
|
+
describe('click-to-select and click-to-place', () => {
|
|
235
|
+
const { useDroppable } = require('@dnd-kit/core');
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
useDroppable.mockReturnValue({ setNodeRef: jest.fn(), isOver: false, active: null });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('is a native tab stop (role=button, tabIndex=0) when empty and not disabled', () => {
|
|
242
|
+
const { container } = render(<Blank {...defaultProps} choice={undefined} />);
|
|
243
|
+
const outer = container.firstChild;
|
|
244
|
+
|
|
245
|
+
expect(outer.getAttribute('role')).toBe('button');
|
|
246
|
+
expect(outer.getAttribute('tabindex')).toBe('0');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('is not a tab stop when it already holds a choice (relies on the inner draggable node)', () => {
|
|
250
|
+
const { container } = render(<Blank {...defaultProps} />);
|
|
251
|
+
const outer = container.firstChild;
|
|
252
|
+
|
|
253
|
+
expect(outer.getAttribute('role')).toBeNull();
|
|
254
|
+
expect(outer.getAttribute('tabindex')).toBe('-1');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('is not a tab stop when disabled, even if empty', () => {
|
|
258
|
+
const { container } = render(<Blank {...defaultProps} choice={undefined} disabled={true} />);
|
|
259
|
+
const outer = container.firstChild;
|
|
260
|
+
|
|
261
|
+
expect(outer.getAttribute('tabindex')).toBe('-1');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('does not make the inner draggable node a second tab stop when the blank is empty', () => {
|
|
265
|
+
const { container } = render(<Blank {...defaultProps} choice={undefined} selectedItem={null} />);
|
|
266
|
+
const outer = container.firstChild;
|
|
267
|
+
const inner = outer.firstElementChild;
|
|
268
|
+
|
|
269
|
+
expect(outer.getAttribute('tabindex')).toBe('0');
|
|
270
|
+
// dragAttributes (which carries tabIndex) is only spread onto the inner node when
|
|
271
|
+
// it's the live tab stop (filled and not disabled) — otherwise it's omitted
|
|
272
|
+
// entirely, so no tabindex attribute is present at all here.
|
|
273
|
+
expect(inner.getAttribute('tabindex')).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('makes the inner draggable node the tab stop when the blank is filled and not disabled', () => {
|
|
277
|
+
const { container } = render(<Blank {...defaultProps} selectedItem={null} />);
|
|
278
|
+
const outer = container.firstChild;
|
|
279
|
+
const inner = outer.firstElementChild;
|
|
280
|
+
|
|
281
|
+
expect(inner.getAttribute('tabindex')).toBe('0');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('selects this blank\'s content on click when nothing else is selected', () => {
|
|
285
|
+
const onSelectClick = jest.fn();
|
|
286
|
+
const { container } = render(
|
|
287
|
+
<Blank {...defaultProps} id="3" selectedItem={null} onSelectClick={onSelectClick} />,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
fireEvent.click(container.firstChild);
|
|
291
|
+
|
|
292
|
+
expect(onSelectClick).toHaveBeenCalledWith({
|
|
293
|
+
id: '3',
|
|
294
|
+
choice: defaultProps.choice,
|
|
295
|
+
instanceId: undefined,
|
|
296
|
+
fromChoice: false,
|
|
297
|
+
type: 'MaskBlank',
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('toggles off when clicking its own already-selected content', () => {
|
|
302
|
+
const onSelectClick = jest.fn();
|
|
303
|
+
const selectedItem = { id: '3', choice: defaultProps.choice, instanceId: undefined, fromChoice: false, type: 'MaskBlank' };
|
|
304
|
+
const { container } = render(
|
|
305
|
+
<Blank {...defaultProps} id="3" selectedItem={selectedItem} onSelectClick={onSelectClick} />,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
fireEvent.click(container.firstChild);
|
|
309
|
+
|
|
310
|
+
expect(onSelectClick).toHaveBeenCalledWith(selectedItem);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('places the current selection here when clicking a different, already-filled blank', () => {
|
|
314
|
+
const onPlacementClick = jest.fn();
|
|
315
|
+
const selectedItem = { choice: { value: 'Other' }, instanceId: undefined, fromChoice: true, type: 'MaskBlank' };
|
|
316
|
+
const { container } = render(
|
|
317
|
+
<Blank {...defaultProps} id="3" selectedItem={selectedItem} onPlacementClick={onPlacementClick} />,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
fireEvent.click(container.firstChild);
|
|
321
|
+
|
|
322
|
+
expect(onPlacementClick).toHaveBeenCalledWith('3');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('places the current selection here on Space/Enter when empty', () => {
|
|
326
|
+
const onPlacementClick = jest.fn();
|
|
327
|
+
const selectedItem = { choice: { value: 'Other' }, instanceId: undefined, fromChoice: true, type: 'MaskBlank' };
|
|
328
|
+
const { container } = render(
|
|
329
|
+
<Blank {...defaultProps} id="3" choice={undefined} selectedItem={selectedItem} onPlacementClick={onPlacementClick} />,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
fireEvent.keyDown(container.firstChild, { code: 'Space' });
|
|
333
|
+
|
|
334
|
+
expect(onPlacementClick).toHaveBeenCalledWith('3');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('does nothing on click when empty and nothing is selected', () => {
|
|
338
|
+
const onSelectClick = jest.fn();
|
|
339
|
+
const onPlacementClick = jest.fn();
|
|
340
|
+
const { container } = render(
|
|
341
|
+
<Blank {...defaultProps} choice={undefined} selectedItem={null} onSelectClick={onSelectClick} onPlacementClick={onPlacementClick} />,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
fireEvent.click(container.firstChild);
|
|
345
|
+
|
|
346
|
+
expect(onSelectClick).not.toHaveBeenCalled();
|
|
347
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('does nothing on click when disabled', () => {
|
|
351
|
+
const onSelectClick = jest.fn();
|
|
352
|
+
const selectedItem = { choice: { value: 'Other' }, instanceId: undefined, fromChoice: true, type: 'MaskBlank' };
|
|
353
|
+
const { container } = render(
|
|
354
|
+
<Blank {...defaultProps} disabled={true} selectedItem={selectedItem} onSelectClick={onSelectClick} />,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
fireEvent.click(container.firstChild);
|
|
358
|
+
|
|
359
|
+
expect(onSelectClick).not.toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('folds click-selection hover into the same highlight a live drag-over shows', () => {
|
|
363
|
+
const selectedItem = { choice: { value: 'Other' }, instanceId: undefined, fromChoice: true, type: 'MaskBlank' };
|
|
364
|
+
const { container } = render(<Blank {...defaultProps} id="3" selectedItem={selectedItem} />);
|
|
365
|
+
const outer = container.firstChild;
|
|
366
|
+
|
|
367
|
+
fireEvent.mouseEnter(outer);
|
|
368
|
+
// The "over" prop drives the same CSS the real isOver-driven highlight uses —
|
|
369
|
+
// assert via the rendered chip's className, since StyledContent forwards `over`.
|
|
370
|
+
// Re-render check: BlankContent receives the folded isOver as true while hovered
|
|
371
|
+
// with a selection active — verified indirectly via the "over" chip class it sets.
|
|
372
|
+
expect(screen.getByText('Cow').closest('.over')).not.toBeNull();
|
|
373
|
+
|
|
374
|
+
fireEvent.mouseLeave(outer);
|
|
375
|
+
expect(screen.getByText('Cow').closest('.over')).toBeNull();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('shows a pointer cursor on hover when something is selected', () => {
|
|
379
|
+
const selectedItem = { choice: { value: 'Other' }, instanceId: undefined, fromChoice: true, type: 'MaskBlank' };
|
|
380
|
+
const { container } = render(<Blank {...defaultProps} id="3" selectedItem={selectedItem} />);
|
|
381
|
+
const outer = container.firstChild;
|
|
382
|
+
const className = Array.from(outer.classList).find((c) => c.startsWith('css-'));
|
|
383
|
+
|
|
384
|
+
const hoverRule = collectEmotionRules().find((r) => r.includes(`.${className}:hover`));
|
|
385
|
+
|
|
386
|
+
expect(hoverRule).toBeDefined();
|
|
387
|
+
expect(hoverRule).toMatch(/cursor:\s*pointer/);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('keeps the default cursor on hover when nothing is selected', () => {
|
|
391
|
+
const { container } = render(<Blank {...defaultProps} id="3" selectedItem={null} />);
|
|
392
|
+
const outer = container.firstChild;
|
|
393
|
+
const className = Array.from(outer.classList).find((c) => c.startsWith('css-'));
|
|
394
|
+
|
|
395
|
+
const hoverRule = collectEmotionRules().find((r) => r.includes(`.${className}:hover`));
|
|
396
|
+
|
|
397
|
+
expect(hoverRule).toBeUndefined();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('keeps the default cursor on hover when disabled, even with something selected', () => {
|
|
401
|
+
const selectedItem = { choice: { value: 'Other' }, instanceId: undefined, fromChoice: true, type: 'MaskBlank' };
|
|
402
|
+
const { container } = render(
|
|
403
|
+
<Blank {...defaultProps} id="3" disabled={true} selectedItem={selectedItem} />,
|
|
404
|
+
);
|
|
405
|
+
const outer = container.firstChild;
|
|
406
|
+
const className = Array.from(outer.classList).find((c) => c.startsWith('css-'));
|
|
407
|
+
|
|
408
|
+
const hoverRule = collectEmotionRules().find((r) => r.includes(`.${className}:hover`));
|
|
409
|
+
|
|
410
|
+
expect(hoverRule).toBeUndefined();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
232
413
|
});
|
package/src/components/blank.jsx
CHANGED
|
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
|
|
|
3
3
|
import { renderMath } from '@pie-lib/math-rendering';
|
|
4
4
|
import debug from 'debug';
|
|
5
5
|
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
|
6
|
-
import { CSS } from '@dnd-kit/utilities';
|
|
7
6
|
import { styled } from '@mui/material/styles';
|
|
8
7
|
import Chip from '@mui/material/Chip';
|
|
9
8
|
import classnames from 'classnames';
|
|
@@ -12,22 +11,32 @@ import { grey } from '@mui/material/colors';
|
|
|
12
11
|
|
|
13
12
|
const log = debug('pie-lib:mask-markup:blank');
|
|
14
13
|
|
|
15
|
-
const StyledContent = styled('span')(({ dragged, over }) => ({
|
|
14
|
+
const StyledContent = styled('span')(({ dragged, over, selected, showsPointerCursor }) => ({
|
|
16
15
|
border: `solid 0px ${color.primary()}`,
|
|
17
16
|
minWidth: '200px',
|
|
18
|
-
touchAction: 'none',
|
|
19
17
|
overflow: 'hidden',
|
|
20
18
|
whiteSpace: 'nowrap',
|
|
21
19
|
opacity: 1,
|
|
20
|
+
cursor: 'default',
|
|
22
21
|
...(over && {
|
|
23
22
|
whiteSpace: 'nowrap',
|
|
24
23
|
overflow: 'hidden',
|
|
25
24
|
}),
|
|
26
|
-
...(dragged && {
|
|
25
|
+
...((dragged || selected) && {
|
|
27
26
|
opacity: 0.5,
|
|
28
27
|
}),
|
|
28
|
+
...(showsPointerCursor && {
|
|
29
|
+
'&:hover': {
|
|
30
|
+
cursor: 'pointer',
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
29
33
|
}));
|
|
30
34
|
|
|
35
|
+
const StyledDragHandle = styled('span')({
|
|
36
|
+
display: 'inline-flex',
|
|
37
|
+
touchAction: 'none',
|
|
38
|
+
});
|
|
39
|
+
|
|
31
40
|
const StyledChip = styled(Chip)(() => ({
|
|
32
41
|
backgroundColor: color.background(),
|
|
33
42
|
border: `2px dashed ${color.text()}`,
|
|
@@ -341,23 +350,40 @@ function DragDropBlank({
|
|
|
341
350
|
emptyResponseAreaWidth,
|
|
342
351
|
emptyResponseAreaHeight,
|
|
343
352
|
instanceId,
|
|
353
|
+
selectedItem,
|
|
354
|
+
onSelectClick,
|
|
355
|
+
onPlacementClick,
|
|
344
356
|
}) {
|
|
357
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
358
|
+
|
|
359
|
+
const dragData = {
|
|
360
|
+
id,
|
|
361
|
+
choice,
|
|
362
|
+
instanceId,
|
|
363
|
+
fromChoice: false, // This is from a blank, not from choices
|
|
364
|
+
type: 'MaskBlank',
|
|
365
|
+
};
|
|
366
|
+
|
|
345
367
|
// Setup draggable functionality
|
|
346
368
|
const {
|
|
347
369
|
attributes: dragAttributes,
|
|
348
370
|
listeners: dragListeners,
|
|
349
371
|
setNodeRef: setDragNodeRef,
|
|
350
|
-
transform,
|
|
351
372
|
isDragging,
|
|
352
373
|
} = useDraggable({
|
|
353
374
|
id: `mask-blank-drag-${id}`,
|
|
354
375
|
disabled: disabled || !choice,
|
|
355
|
-
data:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
376
|
+
data: dragData,
|
|
377
|
+
// dnd-kit's own `attributes` default tabIndex to 0 unconditionally, even when
|
|
378
|
+
// `disabled` — so a non-draggable (empty) blank stays a native Tab stop of its own.
|
|
379
|
+
// For a drop-zone (droppable blank), that duplicates the outer wrapper's own tab stop
|
|
380
|
+
// (see isNativeTabStop below) at the exact same position, so Tab/Shift+Tab has to
|
|
381
|
+
// pass through both to move anywhere visibly, making every other press look like a
|
|
382
|
+
// no-op. Drop it out of the tab order here whenever it isn't independently
|
|
383
|
+
// reachable/interactive, leaving the outer wrapper (for an empty blank) or
|
|
384
|
+
// nothing (for a disabled blank) as the sole stop.
|
|
385
|
+
attributes: {
|
|
386
|
+
tabIndex: choice && !disabled ? 0 : -1,
|
|
361
387
|
},
|
|
362
388
|
});
|
|
363
389
|
|
|
@@ -375,38 +401,82 @@ function DragDropBlank({
|
|
|
375
401
|
},
|
|
376
402
|
});
|
|
377
403
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
404
|
+
const isSelected = !!selectedItem && selectedItem.fromChoice === false && selectedItem.id === id;
|
|
405
|
+
|
|
406
|
+
const handleClick = () => {
|
|
407
|
+
if (disabled) return;
|
|
408
|
+
|
|
409
|
+
if (isSelected) {
|
|
410
|
+
// Clicking this blank's already-selected content again deselects it.
|
|
411
|
+
onSelectClick?.(dragData);
|
|
412
|
+
} else if (selectedItem) {
|
|
413
|
+
// Something else is selected — place it here, whether this blank is currently
|
|
414
|
+
// empty or already filled (the same commitPlacement logic drag-and-drop uses).
|
|
415
|
+
onPlacementClick?.(id);
|
|
416
|
+
} else if (choice) {
|
|
417
|
+
// Nothing selected yet, and this blank holds an answer — select it for moving
|
|
418
|
+
// elsewhere, the same way Tab+Space/Enter does.
|
|
419
|
+
onSelectClick?.(dragData);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Empty blank clicked with nothing selected: nothing to place or select.
|
|
382
423
|
};
|
|
383
424
|
|
|
384
|
-
|
|
385
|
-
|
|
425
|
+
// An empty blank isn't draggable, so dnd-kit's own attributes (only applied to the
|
|
426
|
+
// inner node, and only when draggable) never make it tabbable — this outer wrapper
|
|
427
|
+
// needs its own focus/activation handling so "select a choice, then Tab to a blank
|
|
428
|
+
// and press Space/Enter" works even when the blank is empty. This is independent of,
|
|
429
|
+
// and doesn't change, the existing in-drag Tab-cycling (that's driven by an active
|
|
430
|
+
// dnd-kit drag, not native focus).
|
|
431
|
+
//
|
|
432
|
+
// Only made a native Tab stop when NOT draggable (i.e. empty): when the blank is
|
|
433
|
+
// filled, the inner node is already independently tabbable via dnd-kit's own
|
|
434
|
+
// attributes for the existing pick-up-to-move gesture, and adding a second, outer Tab
|
|
435
|
+
// stop for the same visual chip would add an extra stop to the existing Tab order —
|
|
436
|
+
// the same double-tab-stop bug already found and fixed once in match-list/image-
|
|
437
|
+
// cloze-association's equivalent code.
|
|
438
|
+
const isNativeTabStop = !choice && !disabled;
|
|
439
|
+
const isInnerDraggable = !!choice && !disabled;
|
|
440
|
+
|
|
441
|
+
const handleKeyDown = (e) => {
|
|
442
|
+
if (e.code === 'Space' || e.code === 'Enter') {
|
|
443
|
+
e.preventDefault();
|
|
444
|
+
handleClick();
|
|
445
|
+
}
|
|
386
446
|
};
|
|
387
447
|
|
|
448
|
+
const hasSelection = !!selectedItem;
|
|
449
|
+
const showsHoverEffect = isOver || (hasSelection && isHovered && !disabled);
|
|
450
|
+
|
|
388
451
|
return (
|
|
389
452
|
<StyledContent
|
|
390
|
-
ref={
|
|
391
|
-
|
|
453
|
+
ref={setDropNodeRef}
|
|
454
|
+
role={isNativeTabStop ? 'button' : undefined}
|
|
455
|
+
tabIndex={isNativeTabStop ? 0 : -1}
|
|
456
|
+
onClick={handleClick}
|
|
457
|
+
onKeyDown={isNativeTabStop ? handleKeyDown : undefined}
|
|
458
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
459
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
392
460
|
dragged={isDragging}
|
|
393
|
-
over={
|
|
394
|
-
{
|
|
395
|
-
{
|
|
461
|
+
over={showsHoverEffect}
|
|
462
|
+
selected={isSelected}
|
|
463
|
+
showsPointerCursor={hasSelection && !disabled}
|
|
396
464
|
>
|
|
397
|
-
<
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
465
|
+
<StyledDragHandle ref={setDragNodeRef} {...(isInnerDraggable ? dragAttributes : {})} {...dragListeners}>
|
|
466
|
+
<BlankContent
|
|
467
|
+
id={id}
|
|
468
|
+
disabled={disabled}
|
|
469
|
+
duplicates={duplicates}
|
|
470
|
+
choice={choice}
|
|
471
|
+
isOver={showsHoverEffect}
|
|
472
|
+
dragItem={dragItem?.data?.current}
|
|
473
|
+
correct={correct}
|
|
474
|
+
onChange={onChange}
|
|
475
|
+
emptyResponseAreaWidth={emptyResponseAreaWidth}
|
|
476
|
+
emptyResponseAreaHeight={emptyResponseAreaHeight}
|
|
477
|
+
instanceId={instanceId}
|
|
478
|
+
/>
|
|
479
|
+
</StyledDragHandle>
|
|
410
480
|
</StyledContent>
|
|
411
481
|
);
|
|
412
482
|
}
|
|
@@ -426,6 +496,9 @@ DragDropBlank.propTypes = {
|
|
|
426
496
|
emptyResponseAreaWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
427
497
|
emptyResponseAreaHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
428
498
|
instanceId: PropTypes.string,
|
|
499
|
+
selectedItem: PropTypes.object,
|
|
500
|
+
onSelectClick: PropTypes.func,
|
|
501
|
+
onPlacementClick: PropTypes.func,
|
|
429
502
|
};
|
|
430
503
|
|
|
431
504
|
export default DragDropBlank;
|
|
@@ -6,6 +6,13 @@ import Choices from './choices';
|
|
|
6
6
|
import Choice from './choices/choice';
|
|
7
7
|
import Blank from './components/blank';
|
|
8
8
|
import { withMask } from './with-mask';
|
|
9
|
+
import { closestDroppableKeyboardCoordinates } from './keyboard-coordinates';
|
|
10
|
+
|
|
11
|
+
// A click that lands right after a real drag gesture ends (pointer drag-and-drop, or
|
|
12
|
+
// the browser's own synthetic click for a keyboard Space/Enter) must be ignored by the
|
|
13
|
+
// click-to-select/click-to-place handlers below, or it would immediately reopen or
|
|
14
|
+
// re-trigger a selection for a drag that just completed.
|
|
15
|
+
const CLICK_AFTER_DRAG_GUARD_MS = 250;
|
|
9
16
|
|
|
10
17
|
const Masked = withMask('blank', (props) => (node, data, onChange) => {
|
|
11
18
|
const dataset = node.data?.dataset || {};
|
|
@@ -21,6 +28,9 @@ const Masked = withMask('blank', (props) => (node, data, onChange) => {
|
|
|
21
28
|
emptyResponseAreaHeight,
|
|
22
29
|
instanceId,
|
|
23
30
|
isDragging,
|
|
31
|
+
selectedItem,
|
|
32
|
+
onSelectClick,
|
|
33
|
+
onPlacementClick,
|
|
24
34
|
} = props;
|
|
25
35
|
const choiceId = showCorrectAnswer ? correctResponse[dataset.id] : data[dataset.id];
|
|
26
36
|
// eslint-disable-next-line react/prop-types
|
|
@@ -47,6 +57,9 @@ const Masked = withMask('blank', (props) => (node, data, onChange) => {
|
|
|
47
57
|
}}
|
|
48
58
|
instanceId={instanceId}
|
|
49
59
|
isDragging={isDragging}
|
|
60
|
+
selectedItem={selectedItem}
|
|
61
|
+
onSelectClick={onSelectClick}
|
|
62
|
+
onPlacementClick={onPlacementClick}
|
|
50
63
|
/>
|
|
51
64
|
);
|
|
52
65
|
}
|
|
@@ -58,7 +71,9 @@ export default class DragInTheBlank extends React.Component {
|
|
|
58
71
|
this.state = {
|
|
59
72
|
activeDragItem: null,
|
|
60
73
|
dropAnimation: undefined,
|
|
74
|
+
selectedItem: null,
|
|
61
75
|
};
|
|
76
|
+
this.lastDragEndAt = 0;
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
static propTypes = {
|
|
@@ -89,6 +104,7 @@ export default class DragInTheBlank extends React.Component {
|
|
|
89
104
|
this.setState({
|
|
90
105
|
activeDragItem: active.data.current,
|
|
91
106
|
dropAnimation: undefined, // default during drag
|
|
107
|
+
selectedItem: active.data.current,
|
|
92
108
|
});
|
|
93
109
|
}
|
|
94
110
|
};
|
|
@@ -110,9 +126,40 @@ export default class DragInTheBlank extends React.Component {
|
|
|
110
126
|
return null;
|
|
111
127
|
};
|
|
112
128
|
|
|
129
|
+
// Shared placement logic for both the drag-end path and the click-to-place path, so
|
|
130
|
+
// neither reimplements the other's mutation rules.
|
|
131
|
+
//
|
|
132
|
+
// `targetId === undefined` means "the choice board" — placing there removes the item
|
|
133
|
+
// from wherever it currently is (mirroring image-cloze-association's
|
|
134
|
+
// `containerIndex === undefined` convention for its own choices pool).
|
|
135
|
+
commitPlacement = (draggedItem, targetId) => {
|
|
136
|
+
const { onChange, value } = this.props;
|
|
137
|
+
|
|
138
|
+
if (!onChange) return;
|
|
139
|
+
|
|
140
|
+
if (targetId === undefined) {
|
|
141
|
+
if (!draggedItem.fromChoice && draggedItem.id) {
|
|
142
|
+
const newValue = { ...value };
|
|
143
|
+
delete newValue[draggedItem.id];
|
|
144
|
+
onChange(newValue);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (draggedItem.fromChoice === true) {
|
|
150
|
+
const newValue = { ...value };
|
|
151
|
+
newValue[targetId] = draggedItem.choice.id;
|
|
152
|
+
onChange(newValue);
|
|
153
|
+
} else if (draggedItem.id && draggedItem.id !== targetId) {
|
|
154
|
+
const newValue = { ...value };
|
|
155
|
+
newValue[targetId] = draggedItem.choice.id;
|
|
156
|
+
delete newValue[draggedItem.id];
|
|
157
|
+
onChange(newValue);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
113
161
|
handleDragEnd = (event) => {
|
|
114
162
|
const { active, over } = event;
|
|
115
|
-
const { onChange, value } = this.props;
|
|
116
163
|
|
|
117
164
|
const draggedData = active?.data?.current;
|
|
118
165
|
const dropData = over?.data?.current;
|
|
@@ -126,29 +173,83 @@ export default class DragInTheBlank extends React.Component {
|
|
|
126
173
|
dropAnimation: isValidDrop ? null : undefined,
|
|
127
174
|
});
|
|
128
175
|
|
|
129
|
-
|
|
176
|
+
this.cancelSelection();
|
|
177
|
+
this.lastDragEndAt = Date.now();
|
|
130
178
|
|
|
131
|
-
|
|
132
|
-
const targetId = dropData.id;
|
|
179
|
+
if (!isValidDrop) return;
|
|
133
180
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
181
|
+
const targetId = dropData.toChoiceBoard === true ? undefined : dropData.id;
|
|
182
|
+
|
|
183
|
+
this.commitPlacement(draggedData, targetId);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
onDragCancel = () => {
|
|
187
|
+
this.setState({ activeDragItem: null, dropAnimation: undefined });
|
|
188
|
+
this.cancelSelection();
|
|
189
|
+
this.lastDragEndAt = Date.now();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
isSameItem = (a, b) => {
|
|
193
|
+
if (!a || !b || a.fromChoice !== b.fromChoice) return false;
|
|
194
|
+
return a.fromChoice ? a.choice.id === b.choice.id : a.id === b.id;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Click-to-select semantics: selecting the currently-selected item again clears the
|
|
198
|
+
// selection instead of re-selecting it.
|
|
199
|
+
toggleItemSelection = (data) => {
|
|
200
|
+
this.setState((state) => ({
|
|
201
|
+
selectedItem: this.isSameItem(state.selectedItem, data) ? null : data,
|
|
202
|
+
}));
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
cancelSelection = () => {
|
|
206
|
+
this.setState({ selectedItem: null });
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// If a real dnd-kit drag (started via keyboard Space/Enter) is still live when a
|
|
210
|
+
// click completes the placement below, it needs to be cleanly ended — otherwise
|
|
211
|
+
// dnd-kit would still think a drag is in progress. Escape is already configured as
|
|
212
|
+
// this sensor's cancel key (see the keyboardCodes passed to DragProvider below), and
|
|
213
|
+
// dispatching it as a real DOM KeyboardEvent is how dnd-kit's own document-level
|
|
214
|
+
// listener is reached from outside its sensor.
|
|
215
|
+
//
|
|
216
|
+
// Only dispatch when a drag is actually live — this is a synthetic Escape keydown on
|
|
217
|
+
// `document`, so an unconditional dispatch would also be observed by any other
|
|
218
|
+
// document-level Escape listener (host player modals/dialogs, or another mounted
|
|
219
|
+
// instance of this same component) even when nothing here actually needed cancelling.
|
|
220
|
+
endAnyLiveKeyboardDrag = () => {
|
|
221
|
+
if (!this.state.activeDragItem) return;
|
|
222
|
+
|
|
223
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape', bubbles: true, cancelable: true }));
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
placeSelectedItem = (targetId) => {
|
|
227
|
+
const { selectedItem } = this.state;
|
|
228
|
+
|
|
229
|
+
if (!selectedItem) return;
|
|
230
|
+
|
|
231
|
+
this.commitPlacement(selectedItem, targetId);
|
|
232
|
+
this.cancelSelection();
|
|
233
|
+
this.endAnyLiveKeyboardDrag();
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
isClickSoonAfterDragEnd = () => Date.now() - this.lastDragEndAt < CLICK_AFTER_DRAG_GUARD_MS;
|
|
237
|
+
|
|
238
|
+
onItemClick = (data) => {
|
|
239
|
+
if (this.isClickSoonAfterDragEnd()) return;
|
|
240
|
+
|
|
241
|
+
// End any still-live keyboard drag BEFORE toggling the new selection: ending it
|
|
242
|
+
// also cancels the current selection as a side effect (see onDragCancel above), so
|
|
243
|
+
// doing it before — not after — lets this click's own selection be the one that
|
|
244
|
+
// sticks.
|
|
245
|
+
this.endAnyLiveKeyboardDrag();
|
|
246
|
+
this.toggleItemSelection(data);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
onPlacementClick = (targetId) => {
|
|
250
|
+
if (this.isClickSoonAfterDragEnd()) return;
|
|
251
|
+
|
|
252
|
+
this.placeSelectedItem(targetId);
|
|
152
253
|
};
|
|
153
254
|
|
|
154
255
|
getPositionDirection = (choicePosition) => {
|
|
@@ -203,7 +304,10 @@ export default class DragInTheBlank extends React.Component {
|
|
|
203
304
|
<DragProvider
|
|
204
305
|
onDragStart={this.handleDragStart}
|
|
205
306
|
onDragEnd={this.handleDragEnd}
|
|
307
|
+
onDragCancel={this.onDragCancel}
|
|
206
308
|
collisionDetection={rectIntersection}
|
|
309
|
+
keyboardCoordinateGetter={closestDroppableKeyboardCoordinates}
|
|
310
|
+
keyboardCodes={{ start: ['Space', 'Enter'], cancel: ['Escape'], end: ['Space', 'Enter'] }}
|
|
207
311
|
>
|
|
208
312
|
<div ref={(ref) => (this.rootRef = ref)} style={style}>
|
|
209
313
|
<Choices
|
|
@@ -213,6 +317,9 @@ export default class DragInTheBlank extends React.Component {
|
|
|
213
317
|
duplicates={duplicates}
|
|
214
318
|
disabled={disabled}
|
|
215
319
|
instanceId={instanceId}
|
|
320
|
+
selectedItem={this.state.selectedItem}
|
|
321
|
+
onSelectClick={this.onItemClick}
|
|
322
|
+
onPlacementClick={this.onPlacementClick}
|
|
216
323
|
/>
|
|
217
324
|
<Masked
|
|
218
325
|
elementType="drag-in-the-blank"
|
|
@@ -230,6 +337,9 @@ export default class DragInTheBlank extends React.Component {
|
|
|
230
337
|
emptyResponseAreaHeight={emptyResponseAreaHeight}
|
|
231
338
|
instanceId={instanceId}
|
|
232
339
|
isDragging={!!this.state.activeDragItem}
|
|
340
|
+
selectedItem={this.state.selectedItem}
|
|
341
|
+
onSelectClick={this.onItemClick}
|
|
342
|
+
onPlacementClick={this.onPlacementClick}
|
|
233
343
|
/>
|
|
234
344
|
<DragOverlay style={{ pointerEvents: 'none' }} dropAnimation={this.state.dropAnimation}>
|
|
235
345
|
{this.renderDragOverlay()}
|