@pie-lib/mask-markup 3.1.0-beta.5 → 3.1.0
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/CHANGELOG.md +83 -0
- package/LICENSE.md +5 -0
- 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/components/dropdown.js +2 -9
- package/lib/components/dropdown.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/lib/mask.js +20 -15
- package/lib/mask.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/drag-in-the-blank.test.js +147 -2
- package/src/__tests__/keyboard-coordinates.test.js +202 -0
- package/src/__tests__/mask.test.js +98 -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/components/dropdown.jsx +2 -9
- package/src/drag-in-the-blank.jsx +132 -22
- package/src/keyboard-coordinates.js +147 -0
- package/src/mask.jsx +13 -11
- package/lib/__tests__/drag-in-the-blank.test.js +0 -129
- package/lib/__tests__/drag-in-the-blank.test.js.map +0 -1
- package/lib/__tests__/index.test.js +0 -39
- package/lib/__tests__/index.test.js.map +0 -1
- package/lib/__tests__/mask.test.js +0 -344
- package/lib/__tests__/mask.test.js.map +0 -1
- package/lib/__tests__/serialization.test.js +0 -44
- package/lib/__tests__/serialization.test.js.map +0 -1
- package/lib/__tests__/utils.js +0 -14
- package/lib/__tests__/utils.js.map +0 -1
- package/lib/__tests__/with-mask.test.js +0 -110
- package/lib/__tests__/with-mask.test.js.map +0 -1
- package/lib/choices/__tests__/index.test.js +0 -139
- package/lib/choices/__tests__/index.test.js.map +0 -1
- package/lib/components/__tests__/blank.test.js +0 -293
- package/lib/components/__tests__/blank.test.js.map +0 -1
- package/lib/components/__tests__/correct-input.test.js +0 -132
- package/lib/components/__tests__/correct-input.test.js.map +0 -1
- package/lib/components/__tests__/dropdown.test.js +0 -202
- package/lib/components/__tests__/dropdown.test.js.map +0 -1
- package/lib/components/__tests__/input.test.js +0 -129
- package/lib/components/__tests__/input.test.js.map +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { render, screen } from '@testing-library/react';
|
|
3
3
|
import DragInTheBlank from '../drag-in-the-blank';
|
|
4
|
+
import { closestDroppableKeyboardCoordinates } from '../keyboard-coordinates';
|
|
4
5
|
|
|
5
6
|
const markup = `<div>
|
|
6
7
|
<img src="https://image.shutterstock.com/image-vector/cow-jumped-over-moon-traditional-260nw-1152899330.jpg"></img>
|
|
@@ -14,11 +15,14 @@ const markup = `<div>
|
|
|
14
15
|
</div>`;
|
|
15
16
|
const choice = (v, id) => ({ value: v, id });
|
|
16
17
|
|
|
18
|
+
let capturedDragProviderProps;
|
|
19
|
+
|
|
17
20
|
// Mock DragProvider and DragDroppablePlaceholder to avoid DndContext requirement
|
|
18
21
|
jest.mock('@pie-lib/drag', () => ({
|
|
19
|
-
DragProvider: (
|
|
22
|
+
DragProvider: (props) => {
|
|
23
|
+
capturedDragProviderProps = props;
|
|
20
24
|
// Simple wrapper that doesn't require DndContext
|
|
21
|
-
return <div data-testid="drag-provider">{children}</div>;
|
|
25
|
+
return <div data-testid="drag-provider">{props.children}</div>;
|
|
22
26
|
},
|
|
23
27
|
DragDroppablePlaceholder: ({ children, disabled, instanceId }) => {
|
|
24
28
|
// Simple wrapper that doesn't require useDroppable
|
|
@@ -108,4 +112,145 @@ describe('DragInTheBlank', () => {
|
|
|
108
112
|
expect(container.firstChild).toBeInTheDocument();
|
|
109
113
|
});
|
|
110
114
|
});
|
|
115
|
+
|
|
116
|
+
describe('selection state', () => {
|
|
117
|
+
const baseProps = {
|
|
118
|
+
value: {},
|
|
119
|
+
onChange: jest.fn(),
|
|
120
|
+
choices: [{ id: '0', value: 'firstChoice' }, { id: '1', value: 'secondChoice' }],
|
|
121
|
+
markup: 'text {{0}} more {{1}} end',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
baseProps.onChange.mockClear();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('mirrors an active drag into selectedItem on drag start', () => {
|
|
129
|
+
const instance = new DragInTheBlank(baseProps);
|
|
130
|
+
instance.setState = (s) => Object.assign(instance.state, typeof s === 'function' ? s(instance.state) : s);
|
|
131
|
+
const data = { id: '0', choice: { id: '0', value: 'X' }, fromChoice: false, type: 'MaskBlank' };
|
|
132
|
+
|
|
133
|
+
instance.handleDragStart({ active: { data: { current: data } } });
|
|
134
|
+
|
|
135
|
+
expect(instance.state.selectedItem).toEqual(data);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('toggleItemSelection selects, then deselects the same item', () => {
|
|
139
|
+
const instance = new DragInTheBlank(baseProps);
|
|
140
|
+
instance.setState = (s) => Object.assign(instance.state, typeof s === 'function' ? s(instance.state) : s);
|
|
141
|
+
const data = { choice: { id: '0' }, fromChoice: true, type: 'MaskBlank' };
|
|
142
|
+
|
|
143
|
+
instance.toggleItemSelection(data);
|
|
144
|
+
expect(instance.state.selectedItem).toEqual(data);
|
|
145
|
+
|
|
146
|
+
instance.toggleItemSelection(data);
|
|
147
|
+
expect(instance.state.selectedItem).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('placeSelectedItem places a pool choice into a blank via commitPlacement/onChange', () => {
|
|
151
|
+
const instance = new DragInTheBlank(baseProps);
|
|
152
|
+
instance.setState = (s) => Object.assign(instance.state, typeof s === 'function' ? s(instance.state) : s);
|
|
153
|
+
const poolChoice = { choice: { id: '0' }, instanceId: 'x', fromChoice: true, type: 'MaskBlank' };
|
|
154
|
+
|
|
155
|
+
instance.toggleItemSelection(poolChoice);
|
|
156
|
+
instance.placeSelectedItem('blank-1');
|
|
157
|
+
|
|
158
|
+
expect(baseProps.onChange).toHaveBeenCalledWith({ 'blank-1': '0' });
|
|
159
|
+
expect(instance.state.selectedItem).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('placeSelectedItem with targetId undefined removes a placed item (returns it to the board)', () => {
|
|
163
|
+
const props = { ...baseProps, value: { 'blank-1': '0' } };
|
|
164
|
+
const instance = new DragInTheBlank(props);
|
|
165
|
+
instance.setState = (s) => Object.assign(instance.state, typeof s === 'function' ? s(instance.state) : s);
|
|
166
|
+
const placedItem = { id: 'blank-1', choice: { id: '0' }, instanceId: 'x', fromChoice: false, type: 'MaskBlank' };
|
|
167
|
+
|
|
168
|
+
instance.toggleItemSelection(placedItem);
|
|
169
|
+
instance.placeSelectedItem(undefined);
|
|
170
|
+
|
|
171
|
+
expect(props.onChange).toHaveBeenCalledWith({});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('placeSelectedItem does nothing when nothing is selected', () => {
|
|
175
|
+
const instance = new DragInTheBlank(baseProps);
|
|
176
|
+
instance.setState = (s) => Object.assign(instance.state, typeof s === 'function' ? s(instance.state) : s);
|
|
177
|
+
|
|
178
|
+
instance.placeSelectedItem('blank-1');
|
|
179
|
+
|
|
180
|
+
expect(baseProps.onChange).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('onItemClick and onPlacementClick are ignored for a short window right after a drag ends', () => {
|
|
184
|
+
const instance = new DragInTheBlank(baseProps);
|
|
185
|
+
instance.setState = (s) => Object.assign(instance.state, typeof s === 'function' ? s(instance.state) : s);
|
|
186
|
+
const data = { choice: { id: '0' }, fromChoice: true, type: 'MaskBlank' };
|
|
187
|
+
|
|
188
|
+
instance.handleDragEnd({ active: null, over: null });
|
|
189
|
+
instance.onItemClick(data);
|
|
190
|
+
|
|
191
|
+
expect(instance.state.selectedItem).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('onPlacementClick is ignored for a short window right after a drag ends', () => {
|
|
195
|
+
const instance = new DragInTheBlank(baseProps);
|
|
196
|
+
instance.setState = (s) => Object.assign(instance.state, typeof s === 'function' ? s(instance.state) : s);
|
|
197
|
+
const data = { choice: { id: '0' }, fromChoice: true, type: 'MaskBlank' };
|
|
198
|
+
|
|
199
|
+
instance.handleDragEnd({ active: null, over: null });
|
|
200
|
+
// Select an item after the drag-end guard is armed, so placeSelectedItem would
|
|
201
|
+
// have something to place if the guard didn't short-circuit it first.
|
|
202
|
+
instance.toggleItemSelection(data);
|
|
203
|
+
instance.onPlacementClick('blank-1');
|
|
204
|
+
|
|
205
|
+
expect(baseProps.onChange).not.toHaveBeenCalled();
|
|
206
|
+
expect(instance.state.selectedItem).toEqual(data);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("onDragEnd dropping a blank's own content back onto its own slot is a no-op", () => {
|
|
210
|
+
const props = { ...baseProps, value: { 'blank-1': '0' } };
|
|
211
|
+
const instance = new DragInTheBlank(props);
|
|
212
|
+
instance.setState = (s) => Object.assign(instance.state, typeof s === 'function' ? s(instance.state) : s);
|
|
213
|
+
const draggedItem = { id: 'blank-1', choice: { id: '0' }, fromChoice: false, type: 'MaskBlank' };
|
|
214
|
+
|
|
215
|
+
instance.handleDragEnd({
|
|
216
|
+
active: { data: { current: draggedItem } },
|
|
217
|
+
over: { data: { current: { id: 'blank-1', accepts: ['MaskBlank'] } } },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(props.onChange).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('DragProvider wiring', () => {
|
|
225
|
+
it('passes the Tab/Shift+Tab coordinate getter and keyboard codes to DragProvider', () => {
|
|
226
|
+
render(
|
|
227
|
+
<DragInTheBlank
|
|
228
|
+
value={{}}
|
|
229
|
+
onChange={jest.fn()}
|
|
230
|
+
choices={[]}
|
|
231
|
+
markup="text {{0}} end"
|
|
232
|
+
/>,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(capturedDragProviderProps.keyboardCoordinateGetter).toBe(closestDroppableKeyboardCoordinates);
|
|
236
|
+
expect(capturedDragProviderProps.keyboardCodes).toEqual({
|
|
237
|
+
start: ['Space', 'Enter'],
|
|
238
|
+
cancel: ['Escape'],
|
|
239
|
+
end: ['Space', 'Enter'],
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('passes onDragCancel to DragProvider', () => {
|
|
244
|
+
render(
|
|
245
|
+
<DragInTheBlank
|
|
246
|
+
value={{}}
|
|
247
|
+
onChange={jest.fn()}
|
|
248
|
+
choices={[]}
|
|
249
|
+
markup="text {{0}} end"
|
|
250
|
+
/>,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(typeof capturedDragProviderProps.onDragCancel).toBe('function');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
111
256
|
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { KeyboardCode } from '@dnd-kit/core';
|
|
2
|
+
import { closestDroppableKeyboardCoordinates } from '../keyboard-coordinates';
|
|
3
|
+
|
|
4
|
+
function rectsToContext(rects) {
|
|
5
|
+
const droppableRects = new Map(Object.entries(rects));
|
|
6
|
+
const droppableContainers = new Map(Object.keys(rects).map((id) => [id, { disabled: false }]));
|
|
7
|
+
|
|
8
|
+
return { droppableRects, droppableContainers };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeEvent(code, shiftKey = false) {
|
|
12
|
+
return { code, preventDefault: () => {}, shiftKey };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Mirrors dnd-kit's own KeyboardSensor, which always derives collisionRect's top-left
|
|
16
|
+
// from the dragged item's current on-screen position on every keydown — so a test's
|
|
17
|
+
// collisionRect must track currentCoordinates the same way on every simulated press,
|
|
18
|
+
// not stay fixed while currentCoordinates changes across multiple presses.
|
|
19
|
+
function press(rects, currentCoordinates, itemSize, event, active) {
|
|
20
|
+
const context = {
|
|
21
|
+
...rectsToContext(rects),
|
|
22
|
+
collisionRect: { left: currentCoordinates.x, top: currentCoordinates.y, ...itemSize },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return closestDroppableKeyboardCoordinates(event, { active, context, currentCoordinates });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('closestDroppableKeyboardCoordinates', () => {
|
|
29
|
+
describe('arrow keys', () => {
|
|
30
|
+
it("nudges the dragged item by dnd-kit's own default step (25px), unchanged", () => {
|
|
31
|
+
const context = { ...rectsToContext({}), collisionRect: { left: 0, top: 0, width: 100, height: 40 } };
|
|
32
|
+
const currentCoordinates = { x: 10, y: 20 };
|
|
33
|
+
|
|
34
|
+
expect(
|
|
35
|
+
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Down), { context, currentCoordinates }),
|
|
36
|
+
).toEqual({ x: 10, y: 45 });
|
|
37
|
+
expect(
|
|
38
|
+
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Up), { context, currentCoordinates }),
|
|
39
|
+
).toEqual({ x: 10, y: -5 });
|
|
40
|
+
expect(
|
|
41
|
+
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Right), { context, currentCoordinates }),
|
|
42
|
+
).toEqual({ x: 35, y: 20 });
|
|
43
|
+
expect(
|
|
44
|
+
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Left), { context, currentCoordinates }),
|
|
45
|
+
).toEqual({ x: -15, y: 20 });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('Tab / Shift+Tab', () => {
|
|
50
|
+
const blank0Rect = { left: 0, top: 0, width: 120, height: 40, right: 120, bottom: 40 };
|
|
51
|
+
const blank1Rect = { left: 0, top: 60, width: 120, height: 40, right: 120, bottom: 100 };
|
|
52
|
+
const boardRect = { left: 0, top: 120, width: 400, height: 300, right: 400, bottom: 420 };
|
|
53
|
+
const baseRects = {
|
|
54
|
+
'mask-blank-drop-0': blank0Rect,
|
|
55
|
+
'mask-blank-drop-1': blank1Rect,
|
|
56
|
+
'drag-in-the-blank-droppable': boardRect,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
it('jumps to the next droppable in top-to-bottom order, landing at its center-left point', () => {
|
|
60
|
+
const currentCoordinates = { x: blank0Rect.left, y: blank0Rect.top };
|
|
61
|
+
|
|
62
|
+
const next = press(baseRects, currentCoordinates, { width: 120, height: 40 }, makeEvent('Tab'));
|
|
63
|
+
|
|
64
|
+
expect(next).toEqual({ x: blank1Rect.left, y: blank1Rect.top + blank1Rect.height / 2 });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('cycles backwards with Shift+Tab, wrapping to the choice board', () => {
|
|
68
|
+
const currentCoordinates = { x: blank0Rect.left, y: blank0Rect.top };
|
|
69
|
+
|
|
70
|
+
const next = press(baseRects, currentCoordinates, { width: 120, height: 40 }, makeEvent('Tab', true));
|
|
71
|
+
|
|
72
|
+
expect(next).toEqual({ x: boardRect.left, y: boardRect.top + boardRect.height / 2 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("excludes a dragged blank's own drop-zone from the cycle", () => {
|
|
76
|
+
// A blank is simultaneously draggable and droppable for its own slot (the same
|
|
77
|
+
// shape as match-list's placed "target" tiles). Without excluding it, containment
|
|
78
|
+
// would match the blank to itself and cycle relative to itself instead of its
|
|
79
|
+
// real neighbors — this test is constructed so the two behaviors give different,
|
|
80
|
+
// distinguishable results (not just coincidentally the same answer).
|
|
81
|
+
const blankARect = { left: 0, top: 0, width: 120, height: 40, right: 120, bottom: 40 };
|
|
82
|
+
const blankBRect = { left: 0, top: 60, width: 120, height: 40, right: 120, bottom: 100 };
|
|
83
|
+
const blankCRect = { left: 0, top: 120, width: 120, height: 40, right: 120, bottom: 160 };
|
|
84
|
+
const rects = {
|
|
85
|
+
'mask-blank-drop-a': blankARect,
|
|
86
|
+
'mask-blank-drop-b': blankBRect,
|
|
87
|
+
'mask-blank-drop-c': blankCRect,
|
|
88
|
+
};
|
|
89
|
+
const active = { data: { current: { id: 'b', fromChoice: false, type: 'MaskBlank' } } };
|
|
90
|
+
// Sitting exactly at blank B's own position.
|
|
91
|
+
const currentCoordinates = { x: blankBRect.left, y: blankBRect.top };
|
|
92
|
+
|
|
93
|
+
const next = press(rects, currentCoordinates, { width: 120, height: 40 }, makeEvent('Tab', true), active);
|
|
94
|
+
|
|
95
|
+
// With B's own slot excluded, Shift+Tab from B's position falls back to
|
|
96
|
+
// nearest-by-dropPosition among the remaining targets (A and C) — A is closer to
|
|
97
|
+
// B's actual position, making A the resolved "current", so reverse from there
|
|
98
|
+
// lands on C. Without the exclusion, containment would match B to itself
|
|
99
|
+
// directly (index 1 of [A, B, C] sorted by position), landing on A instead.
|
|
100
|
+
expect(next).toEqual({ x: blankCRect.left, y: blankCRect.top + blankCRect.height / 2 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('keeps advancing on repeated Shift+Tab when the choice board is much wider than the dragged item', () => {
|
|
104
|
+
// Regression case for the bug already found and fixed in match-list/image-cloze-
|
|
105
|
+
// association: matching the "current" target by reconstructing a center from the
|
|
106
|
+
// dragged item's own small size (instead of checking which target's rect actually
|
|
107
|
+
// contains it) makes the *next* press re-match a completely different droppable
|
|
108
|
+
// once the item is actually sitting on a wide target, so it looks like the press
|
|
109
|
+
// does nothing.
|
|
110
|
+
const wideBlankRect = { left: 0, top: 0, width: 900, height: 40, right: 900, bottom: 40 };
|
|
111
|
+
const rects = { 'mask-blank-drop-0': wideBlankRect, 'drag-in-the-blank-droppable': boardRect };
|
|
112
|
+
const itemSize = { width: 100, height: 40 };
|
|
113
|
+
|
|
114
|
+
const afterFirst = press(rects, { x: boardRect.left, y: boardRect.top }, itemSize, makeEvent('Tab', true));
|
|
115
|
+
|
|
116
|
+
expect(afterFirst).toEqual({ x: 0, y: 20 }); // wide blank's center-left point
|
|
117
|
+
|
|
118
|
+
const afterSecond = press(rects, afterFirst, itemSize, makeEvent('Tab', true));
|
|
119
|
+
|
|
120
|
+
expect(afterSecond).toEqual({ x: boardRect.left, y: boardRect.top + boardRect.height / 2 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('correctly identifies the choice board as the current target when picking up an item positioned near its edge, close to a small neighboring blank', () => {
|
|
124
|
+
// Regression case for the OTHER failure mode: matching "current target" purely by
|
|
125
|
+
// nearest-dropPosition (instead of containment first) can misidentify a large
|
|
126
|
+
// droppable as some small, unrelated nearby one, because a large droppable's
|
|
127
|
+
// dropPosition is anchored at its own vertical middle.
|
|
128
|
+
const tallBoardRect = { left: 0, top: 0, width: 400, height: 600, right: 400, bottom: 600 };
|
|
129
|
+
const smallBlankRect = { left: 0, top: -60, width: 100, height: 40, right: 100, bottom: -20 };
|
|
130
|
+
const rects = { 'mask-blank-drop-0': smallBlankRect, 'drag-in-the-blank-droppable': tallBoardRect };
|
|
131
|
+
const itemSize = { width: 100, height: 40 };
|
|
132
|
+
|
|
133
|
+
const currentCoordinates = { x: 0, y: 10 };
|
|
134
|
+
|
|
135
|
+
const next = press(rects, currentCoordinates, itemSize, makeEvent('Tab'));
|
|
136
|
+
|
|
137
|
+
expect(next).toEqual({ x: smallBlankRect.left, y: smallBlankRect.top + smallBlankRect.height / 2 });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('keeps the choice board as a fixed last stop instead of sorting it in by its own center, when the board spans multiple blank rows', () => {
|
|
141
|
+
// Regression case for a real bug found via manual browser testing: with 3
|
|
142
|
+
// choices stacked beside wrapped inline text (`choicePosition: 'right'`), the
|
|
143
|
+
// tall choice board's own center fell numerically BETWEEN blank 2 and blank 3's
|
|
144
|
+
// rows. Sorting it by position the same way as a blank interleaved it into the
|
|
145
|
+
// middle of the cycle, so the very first Tab press after picking up a pool
|
|
146
|
+
// choice jumped straight to blank 3, skipping blanks 1 and 2 entirely.
|
|
147
|
+
const blank0Rect = { left: 0, top: 200, width: 50, height: 50, right: 50, bottom: 250 };
|
|
148
|
+
const blank1Rect = { left: 0, top: 250, width: 50, height: 50, right: 50, bottom: 300 };
|
|
149
|
+
const blank2Rect = { left: 0, top: 285, width: 50, height: 50, right: 50, bottom: 335 };
|
|
150
|
+
// Spans all three choices stacked beside the text — its center (287.5) falls
|
|
151
|
+
// between blank1's (275) and blank2's (310), more than 10px from either, so the
|
|
152
|
+
// old position-based sort placed it between them.
|
|
153
|
+
const tallBoardRect = { left: 400, top: 195, width: 80, height: 185, right: 480, bottom: 380 };
|
|
154
|
+
const rects = {
|
|
155
|
+
'mask-blank-drop-0': blank0Rect,
|
|
156
|
+
'mask-blank-drop-1': blank1Rect,
|
|
157
|
+
'mask-blank-drop-2': blank2Rect,
|
|
158
|
+
'drag-in-the-blank-droppable': tallBoardRect,
|
|
159
|
+
};
|
|
160
|
+
const active = { data: { current: { fromChoice: true, choice: { id: 'x' }, type: 'MaskBlank' } } };
|
|
161
|
+
const itemSize = { width: 48, height: 50 };
|
|
162
|
+
// Picking up a choice from within the board — its own current position, before
|
|
163
|
+
// any movement, sits inside the board's rect.
|
|
164
|
+
const currentCoordinates = { x: 415, y: 205 };
|
|
165
|
+
|
|
166
|
+
const next = press(rects, currentCoordinates, itemSize, makeEvent('Tab'), active);
|
|
167
|
+
|
|
168
|
+
expect(next).toEqual({ x: blank0Rect.left, y: blank0Rect.top + blank0Rect.height / 2 });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('cycles through every blank in position order, then the board, then wraps — even when the board would otherwise sort into the middle', () => {
|
|
172
|
+
const blank0Rect = { left: 0, top: 200, width: 50, height: 50, right: 50, bottom: 250 };
|
|
173
|
+
const blank1Rect = { left: 0, top: 250, width: 50, height: 50, right: 50, bottom: 300 };
|
|
174
|
+
const blank2Rect = { left: 0, top: 285, width: 50, height: 50, right: 50, bottom: 335 };
|
|
175
|
+
const tallBoardRect = { left: 400, top: 195, width: 80, height: 185, right: 480, bottom: 380 };
|
|
176
|
+
const rects = {
|
|
177
|
+
'mask-blank-drop-0': blank0Rect,
|
|
178
|
+
'mask-blank-drop-1': blank1Rect,
|
|
179
|
+
'mask-blank-drop-2': blank2Rect,
|
|
180
|
+
'drag-in-the-blank-droppable': tallBoardRect,
|
|
181
|
+
};
|
|
182
|
+
const active = { data: { current: { fromChoice: true, choice: { id: 'x' }, type: 'MaskBlank' } } };
|
|
183
|
+
const itemSize = { width: 48, height: 50 };
|
|
184
|
+
|
|
185
|
+
let coords = { x: 415, y: 205 };
|
|
186
|
+
const visited = [];
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < 5; i++) {
|
|
189
|
+
coords = press(rects, coords, itemSize, makeEvent('Tab'), active);
|
|
190
|
+
visited.push(coords);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
expect(visited).toEqual([
|
|
194
|
+
{ x: blank0Rect.left, y: blank0Rect.top + blank0Rect.height / 2 },
|
|
195
|
+
{ x: blank1Rect.left, y: blank1Rect.top + blank1Rect.height / 2 },
|
|
196
|
+
{ x: blank2Rect.left, y: blank2Rect.top + blank2Rect.height / 2 },
|
|
197
|
+
{ x: tallBoardRect.left, y: tallBoardRect.top + tallBoardRect.height / 2 },
|
|
198
|
+
{ x: blank0Rect.left, y: blank0Rect.top + blank0Rect.height / 2 }, // wraps
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -185,6 +185,104 @@ describe('Mask', () => {
|
|
|
185
185
|
});
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
describe('mark rendering', () => {
|
|
189
|
+
const markedLayout = (marks, text = 'x') => ({
|
|
190
|
+
nodes: [
|
|
191
|
+
{
|
|
192
|
+
object: 'text',
|
|
193
|
+
leaves: [{ marks, text }],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('renders a mark as an element, not as escaped html text', () => {
|
|
199
|
+
const { container } = render(<Mask {...defaultProps} layout={markedLayout([{ type: 'bold' }])} />);
|
|
200
|
+
|
|
201
|
+
const b = container.querySelector('b');
|
|
202
|
+
expect(b).toBeInTheDocument();
|
|
203
|
+
expect(b.textContent).toBe('x');
|
|
204
|
+
// the tag must not leak into the text content
|
|
205
|
+
expect(container.textContent).toBe('x');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it.each([
|
|
209
|
+
['bold', 'b'],
|
|
210
|
+
['italic', 'em'],
|
|
211
|
+
['underline', 'u'],
|
|
212
|
+
['strikethrough', 's'],
|
|
213
|
+
['code', 'code'],
|
|
214
|
+
['strong', 'strong'],
|
|
215
|
+
])('maps the %s mark to a <%s> element', (markType, tag) => {
|
|
216
|
+
const { container } = render(<Mask {...defaultProps} layout={markedLayout([{ type: markType }])} />);
|
|
217
|
+
|
|
218
|
+
const el = container.querySelector(tag);
|
|
219
|
+
expect(el).toBeInTheDocument();
|
|
220
|
+
expect(el.textContent).toBe('x');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('nests multiple marks, keeping the first mark as the outermost tag', () => {
|
|
224
|
+
const { container } = render(
|
|
225
|
+
<Mask {...defaultProps} layout={markedLayout([{ type: 'bold' }, { type: 'italic' }])} />,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const nested = container.querySelector('b > em');
|
|
229
|
+
expect(nested).toBeInTheDocument();
|
|
230
|
+
expect(nested.textContent).toBe('x');
|
|
231
|
+
expect(container.textContent).toBe('x');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('nests three marks in mark order', () => {
|
|
235
|
+
const { container } = render(
|
|
236
|
+
<Mask
|
|
237
|
+
{...defaultProps}
|
|
238
|
+
layout={markedLayout([{ type: 'underline' }, { type: 'strikethrough' }, { type: 'italic' }])}
|
|
239
|
+
/>,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const nested = container.querySelector('u > s > em');
|
|
243
|
+
expect(nested).toBeInTheDocument();
|
|
244
|
+
expect(nested.textContent).toBe('x');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('renders plain text when no mark maps to a tag', () => {
|
|
248
|
+
const { container } = render(<Mask {...defaultProps} layout={markedLayout([{ type: 'unknown-mark' }])} />);
|
|
249
|
+
|
|
250
|
+
expect(container.textContent).toBe('x');
|
|
251
|
+
expect(container.firstChild.childNodes[0].nodeType).toBe(Node.TEXT_NODE);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('wraps only the marks that map to a tag', () => {
|
|
255
|
+
const { container } = render(
|
|
256
|
+
<Mask {...defaultProps} layout={markedLayout([{ type: 'unknown-mark' }, { type: 'bold' }])} />,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const b = container.querySelector('b');
|
|
260
|
+
expect(b).toBeInTheDocument();
|
|
261
|
+
expect(b.textContent).toBe('x');
|
|
262
|
+
expect(container.textContent).toBe('x');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('renders marked text inline alongside unmarked text', () => {
|
|
266
|
+
const { container } = render(
|
|
267
|
+
<Mask
|
|
268
|
+
{...defaultProps}
|
|
269
|
+
layout={{
|
|
270
|
+
nodes: [
|
|
271
|
+
{ object: 'text', leaves: [{ text: 'Foo ' }] },
|
|
272
|
+
{ object: 'text', leaves: [{ marks: [{ type: 'bold' }, { type: 'italic' }], text: 'x' }] },
|
|
273
|
+
{ object: 'text', leaves: [{ text: ' bar' }] },
|
|
274
|
+
],
|
|
275
|
+
object: 'block',
|
|
276
|
+
type: 'div',
|
|
277
|
+
}}
|
|
278
|
+
/>,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(container.querySelector('b > em').textContent).toBe('x');
|
|
282
|
+
expect(container.textContent).toBe('Foo x bar');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
188
286
|
describe('spacer rendering for DnD components', () => {
|
|
189
287
|
it('adds spacers before and after DnD blank components', () => {
|
|
190
288
|
const mockRenderChildren = jest.fn((n) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
3
|
import Choice from '../choice';
|
|
4
4
|
import { choice } from '../../__tests__/utils';
|
|
5
5
|
import Choices from '../index';
|
|
@@ -65,6 +65,48 @@ describe('index', () => {
|
|
|
65
65
|
const { container } = render(<Choices {...defaultProps} duplicates={true} value={{ 0: '0', 1: '1' }} />);
|
|
66
66
|
expect(container.firstChild).toBeInTheDocument();
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
describe('click-to-place into the pool', () => {
|
|
70
|
+
it('places the selection into the pool when clicking the pool background', () => {
|
|
71
|
+
const onPlacementClick = jest.fn();
|
|
72
|
+
const selectedItem = { id: 'some-blank', fromChoice: false, type: 'MaskBlank' };
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<Choices {...defaultProps} selectedItem={selectedItem} onPlacementClick={onPlacementClick} />,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
fireEvent.click(container.firstChild);
|
|
78
|
+
|
|
79
|
+
expect(onPlacementClick).toHaveBeenCalledWith(undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does nothing when clicking the pool background with nothing selected', () => {
|
|
83
|
+
const onPlacementClick = jest.fn();
|
|
84
|
+
const { container } = render(<Choices {...defaultProps} onPlacementClick={onPlacementClick} />);
|
|
85
|
+
|
|
86
|
+
fireEvent.click(container.firstChild);
|
|
87
|
+
|
|
88
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('clicking a specific choice selects it, not the pool background handler', () => {
|
|
92
|
+
const onPlacementClick = jest.fn();
|
|
93
|
+
const onSelectClick = jest.fn();
|
|
94
|
+
const selectedItem = { id: 'some-blank', fromChoice: false, type: 'MaskBlank' };
|
|
95
|
+
render(
|
|
96
|
+
<Choices
|
|
97
|
+
{...defaultProps}
|
|
98
|
+
selectedItem={selectedItem}
|
|
99
|
+
onSelectClick={onSelectClick}
|
|
100
|
+
onPlacementClick={onPlacementClick}
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
fireEvent.click(screen.getByText('Jumped'));
|
|
105
|
+
|
|
106
|
+
expect(onSelectClick).toHaveBeenCalled();
|
|
107
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
68
110
|
});
|
|
69
111
|
|
|
70
112
|
describe('Choice', () => {
|
|
@@ -105,5 +147,66 @@ describe('index', () => {
|
|
|
105
147
|
expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
|
|
106
148
|
});
|
|
107
149
|
});
|
|
150
|
+
|
|
151
|
+
describe('click-to-select', () => {
|
|
152
|
+
it('selects this choice on click when nothing else is selected', () => {
|
|
153
|
+
const onSelectClick = jest.fn();
|
|
154
|
+
const { container } = render(<Choice {...defaultProps} selectedItem={null} onSelectClick={onSelectClick} />);
|
|
155
|
+
|
|
156
|
+
fireEvent.click(container.firstChild);
|
|
157
|
+
|
|
158
|
+
expect(onSelectClick).toHaveBeenCalledWith({
|
|
159
|
+
choice: defaultProps.choice,
|
|
160
|
+
instanceId: defaultProps.instanceId,
|
|
161
|
+
fromChoice: true,
|
|
162
|
+
type: 'MaskBlank',
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('toggles off (calls onSelectClick again) when clicking the already-selected choice', () => {
|
|
167
|
+
const onSelectClick = jest.fn();
|
|
168
|
+
const selectedItem = { choice: defaultProps.choice, instanceId: defaultProps.instanceId, fromChoice: true, type: 'MaskBlank' };
|
|
169
|
+
const { container } = render(
|
|
170
|
+
<Choice {...defaultProps} selectedItem={selectedItem} onSelectClick={onSelectClick} />,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
fireEvent.click(container.firstChild);
|
|
174
|
+
|
|
175
|
+
expect(onSelectClick).toHaveBeenCalledWith({
|
|
176
|
+
choice: defaultProps.choice,
|
|
177
|
+
instanceId: defaultProps.instanceId,
|
|
178
|
+
fromChoice: true,
|
|
179
|
+
type: 'MaskBlank',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('selects this choice even while a different item is already selected (pool items are never placement targets)', () => {
|
|
184
|
+
const onSelectClick = jest.fn();
|
|
185
|
+
const selectedItem = { id: 'some-blank', fromChoice: false, type: 'MaskBlank' };
|
|
186
|
+
const { container } = render(
|
|
187
|
+
<Choice {...defaultProps} selectedItem={selectedItem} onSelectClick={onSelectClick} />,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
fireEvent.click(container.firstChild);
|
|
191
|
+
|
|
192
|
+
expect(onSelectClick).toHaveBeenCalledWith({
|
|
193
|
+
choice: defaultProps.choice,
|
|
194
|
+
instanceId: defaultProps.instanceId,
|
|
195
|
+
fromChoice: true,
|
|
196
|
+
type: 'MaskBlank',
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('does nothing on click when disabled', () => {
|
|
201
|
+
const onSelectClick = jest.fn();
|
|
202
|
+
const { container } = render(
|
|
203
|
+
<Choice {...defaultProps} disabled={true} selectedItem={null} onSelectClick={onSelectClick} />,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
fireEvent.click(container.firstChild);
|
|
207
|
+
|
|
208
|
+
expect(onSelectClick).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
108
211
|
});
|
|
109
212
|
});
|
package/src/choices/choice.jsx
CHANGED
|
@@ -8,12 +8,12 @@ import { color } from '@pie-lib/render-ui';
|
|
|
8
8
|
|
|
9
9
|
export const DRAG_TYPE = 'MaskBlank';
|
|
10
10
|
|
|
11
|
-
const StyledChoice = styled('span')(({ theme, disabled }) => ({
|
|
11
|
+
const StyledChoice = styled('span')(({ theme, disabled, selected }) => ({
|
|
12
12
|
border: `solid 0px ${theme.palette.primary.main}`,
|
|
13
|
-
borderRadius: theme.spacing(2),
|
|
14
13
|
margin: theme.spacing(0.5),
|
|
15
14
|
transform: 'translate(0, 0)',
|
|
16
15
|
display: 'inline-flex',
|
|
16
|
+
opacity: selected ? 0.7 : 1,
|
|
17
17
|
...(disabled && {}),
|
|
18
18
|
}));
|
|
19
19
|
|
|
@@ -55,7 +55,7 @@ const StyledChipLabel = styled('span')(() => ({
|
|
|
55
55
|
},
|
|
56
56
|
}));
|
|
57
57
|
|
|
58
|
-
export default function Choice({ choice, disabled, instanceId }) {
|
|
58
|
+
export default function Choice({ choice, disabled, instanceId, selectedItem, onSelectClick }) {
|
|
59
59
|
const rootRef = useRef(null);
|
|
60
60
|
|
|
61
61
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
@@ -68,6 +68,15 @@ export default function Choice({ choice, disabled, instanceId }) {
|
|
|
68
68
|
renderMath(rootRef.current);
|
|
69
69
|
}, [choice.value]);
|
|
70
70
|
|
|
71
|
+
const isSelected = !!selectedItem && selectedItem.fromChoice === true && selectedItem.choice.id === choice.id;
|
|
72
|
+
|
|
73
|
+
const handleClick = (e) => {
|
|
74
|
+
if (disabled) return;
|
|
75
|
+
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
onSelectClick?.({ choice, instanceId, fromChoice: true, type: DRAG_TYPE });
|
|
78
|
+
};
|
|
79
|
+
|
|
71
80
|
return (
|
|
72
81
|
<StyledChoice
|
|
73
82
|
ref={setNodeRef}
|
|
@@ -80,6 +89,8 @@ export default function Choice({ choice, disabled, instanceId }) {
|
|
|
80
89
|
: {}
|
|
81
90
|
}
|
|
82
91
|
disabled={disabled}
|
|
92
|
+
selected={isSelected}
|
|
93
|
+
onClick={handleClick}
|
|
83
94
|
{...listeners}
|
|
84
95
|
{...attributes}
|
|
85
96
|
>
|
|
@@ -97,4 +108,6 @@ Choice.propTypes = {
|
|
|
97
108
|
choice: PropTypes.object.isRequired,
|
|
98
109
|
disabled: PropTypes.bool,
|
|
99
110
|
instanceId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
111
|
+
selectedItem: PropTypes.object,
|
|
112
|
+
onSelectClick: PropTypes.func,
|
|
100
113
|
};
|