Skip to content

Commit c0345ed

Browse files
committed
fix: improve a11y of sortable keys
1 parent 87e89c9 commit c0345ed

File tree

8 files changed

+409
-83
lines changed

8 files changed

+409
-83
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useCallback, useRef, useEffect } from '@wordpress/element';
2+
3+
/**
4+
* Custom hook for keyboard-based sorting of list items.
5+
*
6+
* @param {number} index - Current item index in the list
7+
* @param {number} totalItems - Total number of items in the list
8+
* @param {Function} onMove - Callback function to move item (receives fromIndex, toIndex)
9+
* @param {boolean} isActive - External state for whether keyboard mode is active
10+
* @param {Function} setIsActive - Function to set the active state
11+
* @return {Object} Hook state and handlers
12+
*/
13+
const useKeyboardSorting = (
14+
index,
15+
totalItems,
16+
onMove,
17+
isActive,
18+
setIsActive
19+
) => {
20+
const handleRef = useRef(null);
21+
const previousIndexRef = useRef(index);
22+
23+
// Handle keyboard events
24+
const handleKeyDown = useCallback(
25+
(e) => {
26+
if (e.key === ' ' || e.key === 'Spacebar') {
27+
e.preventDefault();
28+
setIsActive(!isActive);
29+
return;
30+
}
31+
32+
if (!isActive) {
33+
return;
34+
}
35+
36+
if (e.key === 'ArrowUp' && index > 0) {
37+
e.preventDefault();
38+
const newIndex = index - 1;
39+
previousIndexRef.current = index;
40+
onMove(index, newIndex);
41+
}
42+
43+
if (e.key === 'ArrowDown' && index < totalItems - 1) {
44+
e.preventDefault();
45+
const newIndex = index + 1;
46+
previousIndexRef.current = index;
47+
onMove(index, newIndex);
48+
}
49+
if (e.key === 'Escape') {
50+
e.preventDefault();
51+
setIsActive(false);
52+
}
53+
},
54+
[isActive, index, totalItems, onMove, setIsActive]
55+
);
56+
57+
const handleBlur = useCallback(() => {
58+
setIsActive(false);
59+
}, [setIsActive]);
60+
61+
useEffect(() => {
62+
if (
63+
isActive &&
64+
handleRef.current &&
65+
previousIndexRef.current !== index
66+
) {
67+
// Small delay to ensure DOM has updated
68+
window.requestAnimationFrame(() => {
69+
if (handleRef.current) {
70+
handleRef.current.focus();
71+
}
72+
});
73+
}
74+
previousIndexRef.current = index;
75+
}, [index, isActive]);
76+
77+
return {
78+
handleRef,
79+
handleKeyDown,
80+
handleBlur,
81+
};
82+
};
83+
84+
export default useKeyboardSorting;

assets/apps/customizer-controls/src/ordering/Ordering.js

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,65 @@ import {
99
ToggleControl,
1010
} from '@wordpress/components';
1111
import { dragHandle, chevronDown, chevronUp } from '@wordpress/icons';
12-
import { useMemo, useState, useCallback, useEffect } from '@wordpress/element';
12+
import {
13+
useMemo,
14+
useState,
15+
useCallback,
16+
useEffect,
17+
useRef,
18+
} from '@wordpress/element';
1319
import { RadioIcons } from '@neve-wp/components';
1420

1521
import { VisibilityIcon, HiddenIcon } from '../common';
22+
import useKeyboardSorting from '../common/useKeyboardSorting';
1623
import ResponsiveRangeComponent from '../responsive-range/ResponsiveRangeComponent';
1724
import ResponsiveRadioButtonsComponent from '../responsive-radio-buttons/ResponsiveRadioButtonsComponent';
1825
import SpacingComponent from '../spacing/SpacingComponent';
1926

20-
const Handle = () => (
21-
<Tooltip text={__('Drag to Reorder', 'neve')}>
22-
<button
23-
aria-label={__('Drag to Reorder', 'neve')}
24-
className="handle"
25-
onClick={(e) => {
26-
e.preventDefault();
27-
}}
28-
>
29-
<Icon icon={dragHandle} size={18} />
30-
</button>
31-
</Tooltip>
32-
);
27+
const Handle = ({ handleRef, onKeyDown, onBlur, isActive }) => {
28+
return (
29+
<Tooltip text={__('Drag to Reorder', 'neve')}>
30+
<button
31+
ref={handleRef}
32+
aria-label={__('Drag to Reorder', 'neve')}
33+
className={classnames('handle', {
34+
'keyboard-active': isActive,
35+
})}
36+
onClick={(e) => {
37+
e.preventDefault();
38+
}}
39+
onKeyDown={onKeyDown}
40+
onBlur={onBlur}
41+
tabIndex={0}
42+
>
43+
<Icon icon={dragHandle} size={18} />
44+
</button>
45+
</Tooltip>
46+
);
47+
};
3348

3449
const Item = ({
3550
item,
3651
onToggle,
3752
components,
3853
allowsToggle = true,
3954
locked = false,
55+
itemIndex,
56+
totalItems,
57+
onMove,
58+
isKeyboardActive,
59+
onKeyboardActiveChange,
4060
}) => {
4161
const label = components[item.id]?.label || components[item.id];
4262

63+
const { handleRef, handleKeyDown, handleBlur } = useKeyboardSorting(
64+
itemIndex,
65+
totalItems,
66+
onMove,
67+
isKeyboardActive,
68+
onKeyboardActiveChange
69+
);
70+
4371
const hasControls = useMemo(() => {
4472
return (
4573
!!components[item.id]?.controls &&
@@ -99,7 +127,12 @@ const Item = ({
99127
</button>
100128
</Tooltip>
101129
) : (
102-
<Handle />
130+
<Handle
131+
handleRef={handleRef}
132+
onKeyDown={handleKeyDown}
133+
onBlur={handleBlur}
134+
isActive={isKeyboardActive}
135+
/>
103136
)}
104137
<span className="label">{label}</span>
105138

@@ -330,6 +363,13 @@ Item.propTypes = {
330363
item: PropTypes.object.isRequired,
331364
onToggle: PropTypes.func.isRequired,
332365
allowsToggle: PropTypes.bool.isRequired,
366+
itemIndex: PropTypes.number.isRequired,
367+
totalItems: PropTypes.number.isRequired,
368+
onMove: PropTypes.func.isRequired,
369+
components: PropTypes.object.isRequired,
370+
locked: PropTypes.bool,
371+
isKeyboardActive: PropTypes.bool.isRequired,
372+
onKeyboardActiveChange: PropTypes.func.isRequired,
333373
className: PropTypes.string,
334374
disabled: PropTypes.bool,
335375
};
@@ -342,6 +382,9 @@ const Ordering = ({
342382
locked = [],
343383
allowsToggle = true,
344384
}) => {
385+
// Track active item by stable id, not by index or object reference.
386+
const activeItemIdRef = useRef(null);
387+
const [, forceUpdate] = useState({});
345388
const handleToggle = (item) => {
346389
const newValue = value.map((e) => {
347390
if (e.id === item) {
@@ -365,6 +408,13 @@ const Ordering = ({
365408
onUpdate(updatedValue);
366409
};
367410

411+
const handleMove = (fromIndex, toIndex) => {
412+
const newValue = [...value];
413+
const [movedItem] = newValue.splice(fromIndex, 1);
414+
newValue.splice(toIndex, 0, movedItem);
415+
// Active id persists because item.id does not change.
416+
handleChange(newValue);
417+
};
368418
value = value.filter((element) => {
369419
return components.hasOwnProperty(element.id);
370420
});
@@ -386,14 +436,26 @@ const Ordering = ({
386436
.filter((element) => {
387437
return components.hasOwnProperty(element.id);
388438
})
389-
.map((item) => (
439+
.map((item, index) => (
390440
<Item
391441
locked={locked.includes(item.id)}
392442
key={item.id}
393443
item={item}
394444
onToggle={handleToggle}
395445
allowsToggle={allowsToggle}
396446
components={components}
447+
itemIndex={index}
448+
totalItems={value.length}
449+
onMove={handleMove}
450+
isKeyboardActive={
451+
activeItemIdRef.current === item.id
452+
}
453+
onKeyboardActiveChange={(active) => {
454+
activeItemIdRef.current = active
455+
? item.id
456+
: null;
457+
forceUpdate({});
458+
}}
397459
/>
398460
))}
399461
</ReactSortable>

assets/apps/customizer-controls/src/repeater/Repeater.js

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
44
import { Button } from '@wordpress/components';
55
import { ReactSortable } from 'react-sortablejs';
66
import { __ } from '@wordpress/i18n';
7+
import { useRef, useState } from '@wordpress/element';
78

89
const Repeater = ({
910
label,
@@ -15,6 +16,20 @@ const Repeater = ({
1516
}) => {
1617
const itemFields =
1718
Object.keys(newItemFields).length > 0 ? newItemFields : fields;
19+
// Track active item by a stable key (slug or generated) independent of index.
20+
const activeItemKeyRef = useRef(null);
21+
const [, forceUpdate] = useState({});
22+
23+
// Ensure each existing item has a stable internal key for keyboard tracking (preserved across moves).
24+
value.forEach((obj) => {
25+
if (!obj.__kbKey) {
26+
obj.__kbKey =
27+
obj.slug ||
28+
'kb-' +
29+
Date.now().toString(36) +
30+
Math.random().toString(36).slice(2, 8);
31+
}
32+
});
1833

1934
const handleToggle = (index) => {
2035
const newValue = [...value];
@@ -56,6 +71,12 @@ const Repeater = ({
5671
newItem[field] = itemFields[field].default;
5772
}
5873

74+
// Assign stable key before pushing so initial activation works.
75+
newItem.__kbKey =
76+
newItem.slug ||
77+
'kb-' +
78+
Date.now().toString(36) +
79+
Math.random().toString(36).slice(2, 8);
5980
newValue.push(newItem);
6081
onUpdate(newValue);
6182
};
@@ -72,18 +93,24 @@ const Repeater = ({
7293
onUpdate(newValue);
7394
};
7495

96+
const handleMove = (fromIndex, toIndex) => {
97+
const newValue = [...value];
98+
const [movedItem] = newValue.splice(fromIndex, 1);
99+
newValue.splice(toIndex, 0, movedItem);
100+
setList(newValue);
101+
};
75102
const setList = (l) => {
103+
const allowed = [
104+
...Object.keys(itemFields),
105+
'title',
106+
'visibility',
107+
'blocked',
108+
'slug',
109+
'__kbKey', // preserve stable keyboard key
110+
];
76111
const final = l.map((i) => {
77112
Object.keys(i).forEach((k) => {
78-
if (
79-
![
80-
...Object.keys(itemFields),
81-
'title',
82-
'visibility',
83-
'blocked',
84-
'slug',
85-
].includes(k)
86-
) {
113+
if (!allowed.includes(k)) {
87114
delete i[k];
88115
}
89116
});
@@ -112,20 +139,32 @@ const Repeater = ({
112139
handle=".handle"
113140
>
114141
{value.map((val, index) => {
142+
const reactKey = val.__kbKey;
115143
return (
116144
<RepeaterItem
117145
className="nv-repeater-item"
118146
newItemFields={newItemFields}
119147
fields={fields}
120148
value={value}
121149
itemIndex={index}
122-
key={'repeater-item-' + (val.slug || index)}
150+
key={'repeater-item-' + reactKey}
123151
onToggle={handleToggle}
124152
onContentChange={(newItemValue) =>
125153
handleContentChange(index, newItemValue)
126154
}
127155
onRemove={handleRemove}
128156
index={index}
157+
totalItems={value.length}
158+
onMove={handleMove}
159+
isKeyboardActive={
160+
activeItemKeyRef.current === val.__kbKey
161+
}
162+
onKeyboardActiveChange={(active) => {
163+
activeItemKeyRef.current = active
164+
? val.__kbKey
165+
: null;
166+
forceUpdate({});
167+
}}
129168
/>
130169
);
131170
})}

0 commit comments

Comments
 (0)