Skip to content

Commit 6604138

Browse files
Merge pull request #1412 from creative-commoners/pulls/6/kb-reorder
ENH Allow reordering elements with space
2 parents 321cecb + a9c4355 commit 6604138

14 files changed

Lines changed: 571 additions & 29 deletions

File tree

client/dist/js/bundle.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/dist/styles/bundle.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/lang/src/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"ElementArchiveAction.DUPLICATE_PERMISSION_DENY": "Duplicate, insufficient permissions",
1111
"ElementEditForm.ERROR_NOTIFICATION": "Error displaying the edit form for this block",
1212
"ElementHeader.BROKEN": "This element is of obsolete type {type}.",
13+
"ElementHeader.DRAG_HANDLE": "Reorder block",
1314
"ElementHeader.EXPAND": "Show editable fields",
1415
"ElementHeader.NOTITLE": "Untitled {type} block",
1516
"ElementHeader.STATE_DRAFT": "Item has not been published yet",

client/src/components/ElementEditor/Element.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const Element = (props) => {
4545
attributes,
4646
listeners,
4747
setNodeRef,
48+
setActivatorNodeRef,
4849
transform,
4950
transition,
5051
isDragging,
@@ -56,6 +57,23 @@ const Element = (props) => {
5657
transition,
5758
};
5859

60+
// Defensive fallback: useSortable() returns listeners as an object or undefined (not guaranteed
61+
// to always be defined), so we normalise to {} to safely call Object.entries() below.
62+
const sortableListeners = listeners || {};
63+
// Split dnd-kit listeners into keyboard vs pointer groups so they can be attached to different
64+
// DOM nodes: pointer listeners stay on the outer element container (for mouse/touch drag),
65+
// while keyboard listeners are forwarded to the drag-handle button inside the header so that
66+
// keyboard-initiated sorting activates only from the focused handle, not the whole block.
67+
const keyboardListeners = {};
68+
const pointerListeners = {};
69+
Object.entries(sortableListeners).forEach(([key, value]) => {
70+
if (key.startsWith('onKey')) {
71+
keyboardListeners[key] = value;
72+
return;
73+
}
74+
pointerListeners[key] = value;
75+
});
76+
5977
const formRenderedIfNeeded = formHasRendered || !props.type.inlineEditable;
6078

6179
useEffect(() => {
@@ -247,6 +265,13 @@ const Element = (props) => {
247265
if (type.broken) {
248266
return;
249267
}
268+
const dragHandle = event.target.closest
269+
? event.target.closest('.element-editor-header__drag-handle')
270+
: null;
271+
if (dragHandle) {
272+
event.stopPropagation();
273+
return;
274+
}
250275
if (event.target.type === 'button') {
251276
// Stop bubbling if the click target was a button within this container
252277
event.stopPropagation();
@@ -268,9 +293,14 @@ const Element = (props) => {
268293
*/
269294
const handleKeyUp = (event) => {
270295
const { nodeName } = event.target;
296+
const dragHandle = event.target.closest
297+
? event.target.closest('.element-editor-header__drag-handle')
298+
: null;
271299
if ((event.key === ' ' || event.key === 'Enter')
272300
// Ignore presses while focusing inputs and textareas
273301
&& !['input', 'textarea'].includes(nodeName.toLowerCase())
302+
// Ignore presses while focused on the drag handle
303+
&& !dragHandle
274304
) {
275305
handleExpand(event);
276306
}
@@ -341,6 +371,7 @@ const Element = (props) => {
341371
return null;
342372
}
343373

374+
const elementDomId = `element-${element.id}`;
344375
const elementClassNames = classNames(
345376
'element-editor__element',
346377
{
@@ -360,17 +391,16 @@ const Element = (props) => {
360391
};
361392

362393
const content = <div
394+
id={elementDomId}
363395
className={elementClassNames}
364396
onClick={handleExpand}
365397
onKeyUp={handleKeyUp}
366398
role="button"
367399
tabIndex={0}
368400
title={getLinkTitle(type)}
369401
key={element.id}
370-
// sortable properties
371402
ref={setNodeRef}
372-
{...attributes}
373-
{...listeners}
403+
{...pointerListeners}
374404
style={style}
375405
>
376406
<ElementContext.Provider value={providerValue}>
@@ -384,6 +414,10 @@ const Element = (props) => {
384414
handleEditTabsClick={handleTabClick}
385415
activeTab={activeTab}
386416
disableTooltip={isDragging}
417+
sortableListeners={keyboardListeners}
418+
sortableAttributes={attributes}
419+
sortableActivatorRef={setActivatorNodeRef}
420+
elementId={elementDomId}
387421
/>
388422
<ContentComponent
389423
id={element.id}
@@ -467,7 +501,7 @@ Element.propTypes = {
467501
activeTab: PropTypes.string,
468502
tabSetName: PropTypes.string,
469503
onActivateTab: PropTypes.func,
470-
isOver: PropTypes.bool.isRequired,
504+
onChangeHasUnsavedChanges: PropTypes.func.isRequired,
471505
saveElement: PropTypes.bool.isRequired,
472506
onBeforeSubmitForm: PropTypes.func.isRequired, // eslint-disable-line react/no-unused-prop-types
473507
onAfterSubmitResponse: PropTypes.func.isRequired,

client/src/components/ElementEditor/Element.scss

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@
1212

1313
&:hover {
1414
.element-editor-header__drag-handle {
15-
display: block;
15+
opacity: 1;
16+
pointer-events: auto;
1617
}
1718
}
1819

1920
&:focus-within {
2021
// Set this explicitly instead of just setting the variable beacause
2122
// we don't want to affect child items
2223
outline-offset: var(--focus-outline-offset-inset);
24+
25+
.element-editor-header__drag-handle {
26+
opacity: 1;
27+
pointer-events: auto;
28+
}
2329
}
2430

2531
&--broken {
@@ -29,5 +35,10 @@
2935
&--dragging {
3036
opacity: 0.3;
3137
cursor: grabbing;
38+
39+
.element-editor-header__drag-handle {
40+
opacity: 1;
41+
pointer-events: auto;
42+
}
3243
}
3344
}

client/src/components/ElementEditor/ElementList.js

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,86 @@ import { compose } from 'redux';
66
import { inject } from 'lib/Injector';
77
import classNames from 'classnames';
88
import i18n from 'i18n';
9-
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
10-
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
9+
import {
10+
DndContext,
11+
closestCenter,
12+
PointerSensor,
13+
KeyboardSensor,
14+
KeyboardCode,
15+
useSensor,
16+
useSensors
17+
} from '@dnd-kit/core';
18+
import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
1119
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
1220
import { getElementTypeConfig } from 'state/editor/elementConfig';
1321

22+
export const keyboardCoordinateGetter = (event, args) => {
23+
event.preventDefault();
24+
const { active, over, droppableContainers } = args.context;
25+
if (!droppableContainers) {
26+
return undefined;
27+
}
28+
if (!active || !active.data || !active.data.current) {
29+
return undefined;
30+
}
31+
const { sortable } = active.data.current;
32+
if (!sortable || !Array.isArray(sortable.items)) {
33+
return undefined;
34+
}
35+
const items = sortable.items;
36+
const overId = over ? over.id : active.id;
37+
const overIndex = items.indexOf(overId);
38+
const activeIndex = items.indexOf(active.id);
39+
if (overIndex === -1 || activeIndex === -1) {
40+
return undefined;
41+
}
42+
const directionUp = -1;
43+
const directionDown = 1;
44+
let nextIndex = overIndex;
45+
let direction = directionDown;
46+
switch (event.code) {
47+
case KeyboardCode.Down:
48+
case KeyboardCode.Right:
49+
nextIndex = Math.min(overIndex + 1, items.length - 1);
50+
break;
51+
case KeyboardCode.Up:
52+
case KeyboardCode.Left:
53+
nextIndex = Math.max(0, overIndex - 1);
54+
direction = directionUp;
55+
break;
56+
default:
57+
return undefined;
58+
}
59+
if (overIndex === nextIndex) {
60+
return undefined;
61+
}
62+
const sortedItems = arrayMove(items, activeIndex, overIndex);
63+
const currentNodeIdAtNextIndex = sortedItems[nextIndex];
64+
if (!droppableContainers.has(currentNodeIdAtNextIndex)) {
65+
return undefined;
66+
}
67+
if (!droppableContainers.has(active.id)) {
68+
return undefined;
69+
}
70+
const activeNode = droppableContainers.get(active.id).node?.current;
71+
if (!activeNode) {
72+
return undefined;
73+
}
74+
const newNode = droppableContainers.get(currentNodeIdAtNextIndex).node?.current;
75+
if (!newNode) {
76+
return undefined;
77+
}
78+
const activeRect = activeNode.getBoundingClientRect();
79+
const newRect = newNode.getBoundingClientRect();
80+
const offset = direction === directionDown
81+
? newRect.top - activeRect.bottom
82+
: activeRect.top - newRect.bottom;
83+
return {
84+
x: 0,
85+
y: activeRect.top + direction * (newRect.height + offset),
86+
};
87+
};
88+
1489
function ElementList({
1590
elements = [],
1691
sharedObject = {
@@ -35,7 +110,6 @@ function ElementList({
35110
const [increment, setIncrement] = useState(0);
36111
const [hasUnsavedChangesBlockIDs, setHasUnsavedChangesBlockIDs] = useState({});
37112
const [validBlockIDs, setValidBlockIDs] = useState({});
38-
39113
// Update the sharedObject so state can be set from entwine.js
40114
sharedObject.setIncrement = setIncrement;
41115
sharedObject.setSaveAllElements = setSaveAllElements;
@@ -139,8 +213,23 @@ function ElementList({
139213
distance: 10
140214
}
141215
}),
216+
useSensor(KeyboardSensor, {
217+
coordinateGetter: keyboardCoordinateGetter
218+
})
142219
);
143220

221+
const handleDragStart = (event) => {
222+
if (onDragStart) {
223+
onDragStart(event);
224+
}
225+
};
226+
227+
const handleDragEnd = (event) => {
228+
if (onDragEnd) {
229+
onDragEnd(event);
230+
}
231+
};
232+
144233
const handleChangeHasUnsavedChanges = (elementID, hasUnsavedChanges) => {
145234
setHasUnsavedChangesBlockIDs({
146235
...hasUnsavedChangesBlockIDs,
@@ -215,8 +304,8 @@ function ElementList({
215304
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
216305
sensors={sensors}
217306
collisionDetection={closestCenter}
218-
onDragStart={onDragStart}
219-
onDragEnd={onDragEnd}
307+
onDragStart={handleDragStart}
308+
onDragEnd={handleDragEnd}
220309
>
221310
<SortableContext
222311
items={elements.map(element => element.id)}
@@ -244,6 +333,7 @@ function ElementList({
244333
{ 'elemental-editor-list--empty': !elements || !elements.length }
245334
);
246335

336+
// return <div className={listClassNames} ref={listRef}>
247337
return <div className={listClassNames}>
248338
{renderLoading()}
249339
{renderBlocks()}

client/src/components/ElementEditor/Header.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const Header = ({
1919
expandable = true,
2020
ElementActionsComponent,
2121
handleEditTabsClick,
22+
sortableListeners,
23+
sortableAttributes,
24+
sortableActivatorRef,
25+
elementId,
2226
}) => {
2327
const [tooltipOpen, setTooltipOpen] = useState(false);
2428

@@ -138,10 +142,20 @@ const Header = ({
138142
}
139143
);
140144
const blockIconId = `element-icon-${element.id}`;
145+
const dragHandleLabel = i18n._t('ElementHeader.DRAG_HANDLE', 'Reorder block');
141146

142147
return (
143148
<div className={containerClasses}>
144-
<div className="element-editor-header__drag-handle">
149+
<div
150+
className="element-editor-header__drag-handle"
151+
ref={sortableActivatorRef}
152+
{...sortableListeners}
153+
{...sortableAttributes}
154+
tabIndex={0}
155+
role="button"
156+
aria-label={dragHandleLabel}
157+
aria-controls={elementId}
158+
>
145159
<span className="font-icon-drag-handle" aria-hidden="true" />
146160
</div>
147161
<div className="element-editor-header__info">
@@ -188,6 +202,12 @@ Header.propTypes = {
188202
ElementActionsComponent: PropTypes.elementType,
189203
previewExpanded: PropTypes.bool,
190204
disableTooltip: PropTypes.bool,
205+
sortableListeners: PropTypes.object,
206+
sortableAttributes: PropTypes.object,
207+
sortableActivatorRef: PropTypes.func,
208+
elementId: PropTypes.string,
209+
expandable: PropTypes.bool,
210+
handleEditTabsClick: PropTypes.func,
191211
};
192212

193213
export { Header as Component };

client/src/components/ElementEditor/Header.scss

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,21 @@
8484
}
8585

8686
&__drag-handle {
87-
display: none;
87+
display: block;
88+
opacity: 0;
89+
pointer-events: none;
8890
position: absolute;
8991
left: 5px;
9092
cursor: grab;
93+
94+
&:focus-visible {
95+
border-radius: 0.125rem;
96+
}
9197
}
9298

9399
&--simple &__drag-handle {
94-
display: block;
100+
opacity: 1;
101+
pointer-events: auto;
95102
}
96103

97104
&--simple &__info {

0 commit comments

Comments
 (0)