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
84 changes: 84 additions & 0 deletions assets/apps/customizer-controls/src/common/useKeyboardSorting.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useCallback, useRef, useEffect } from '@wordpress/element';

/**
* Custom hook for keyboard-based sorting of list items.
*
* @param {number} index - Current item index in the list
* @param {number} totalItems - Total number of items in the list
* @param {Function} onMove - Callback function to move item (receives fromIndex, toIndex)
* @param {boolean} isActive - External state for whether keyboard mode is active
* @param {Function} setIsActive - Function to set the active state
* @return {Object} Hook state and handlers
*/
const useKeyboardSorting = (
index,
totalItems,
onMove,
isActive,
setIsActive
) => {
const handleRef = useRef(null);
const previousIndexRef = useRef(index);

// Handle keyboard events
const handleKeyDown = useCallback(
(e) => {
if (e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
setIsActive(!isActive);
return;
}

if (!isActive) {
return;
}

if (e.key === 'ArrowUp' && index > 0) {
e.preventDefault();
const newIndex = index - 1;
previousIndexRef.current = index;
onMove(index, newIndex);
}

if (e.key === 'ArrowDown' && index < totalItems - 1) {
e.preventDefault();
const newIndex = index + 1;
previousIndexRef.current = index;
onMove(index, newIndex);
}
if (e.key === 'Escape') {
e.preventDefault();
setIsActive(false);
}
},
[isActive, index, totalItems, onMove, setIsActive]
);

const handleBlur = useCallback(() => {
setIsActive(false);
}, [setIsActive]);

useEffect(() => {
if (
isActive &&
handleRef.current &&
previousIndexRef.current !== index
) {
// Small delay to ensure DOM has updated
window.requestAnimationFrame(() => {
if (handleRef.current) {
handleRef.current.focus();
}
});
}
previousIndexRef.current = index;
}, [index, isActive]);

return {
handleRef,
handleKeyDown,
handleBlur,
};
};

export default useKeyboardSorting;
94 changes: 78 additions & 16 deletions assets/apps/customizer-controls/src/ordering/Ordering.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,65 @@ import {
ToggleControl,
} from '@wordpress/components';
import { dragHandle, chevronDown, chevronUp } from '@wordpress/icons';
import { useMemo, useState, useCallback, useEffect } from '@wordpress/element';
import {
useMemo,
useState,
useCallback,
useEffect,
useRef,
} from '@wordpress/element';
import { RadioIcons } from '@neve-wp/components';

import { VisibilityIcon, HiddenIcon } from '../common';
import useKeyboardSorting from '../common/useKeyboardSorting';
import ResponsiveRangeComponent from '../responsive-range/ResponsiveRangeComponent';
import ResponsiveRadioButtonsComponent from '../responsive-radio-buttons/ResponsiveRadioButtonsComponent';
import SpacingComponent from '../spacing/SpacingComponent';

const Handle = () => (
<Tooltip text={__('Drag to Reorder', 'neve')}>
<button
aria-label={__('Drag to Reorder', 'neve')}
className="handle"
onClick={(e) => {
e.preventDefault();
}}
>
<Icon icon={dragHandle} size={18} />
</button>
</Tooltip>
);
const Handle = ({ handleRef, onKeyDown, onBlur, isActive }) => {
return (
<Tooltip text={__('Drag to Reorder', 'neve')}>
<button
ref={handleRef}
aria-label={__('Drag to Reorder', 'neve')}
className={classnames('handle', {
'keyboard-active': isActive,
})}
onClick={(e) => {
e.preventDefault();
}}
onKeyDown={onKeyDown}
onBlur={onBlur}
tabIndex={0}
>
<Icon icon={dragHandle} size={18} />
</button>
</Tooltip>
);
};

const Item = ({
item,
onToggle,
components,
allowsToggle = true,
locked = false,
itemIndex,
totalItems,
onMove,
isKeyboardActive,
onKeyboardActiveChange,
}) => {
const label = components[item.id]?.label || components[item.id];

const { handleRef, handleKeyDown, handleBlur } = useKeyboardSorting(
itemIndex,
totalItems,
onMove,
isKeyboardActive,
onKeyboardActiveChange
);

const hasControls = useMemo(() => {
return (
!!components[item.id]?.controls &&
Expand Down Expand Up @@ -99,7 +127,12 @@ const Item = ({
</button>
</Tooltip>
) : (
<Handle />
<Handle
handleRef={handleRef}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
isActive={isKeyboardActive}
/>
)}
<span className="label">{label}</span>

Expand Down Expand Up @@ -330,6 +363,13 @@ Item.propTypes = {
item: PropTypes.object.isRequired,
onToggle: PropTypes.func.isRequired,
allowsToggle: PropTypes.bool.isRequired,
itemIndex: PropTypes.number.isRequired,
totalItems: PropTypes.number.isRequired,
onMove: PropTypes.func.isRequired,
components: PropTypes.object.isRequired,
locked: PropTypes.bool,
isKeyboardActive: PropTypes.bool.isRequired,
onKeyboardActiveChange: PropTypes.func.isRequired,
className: PropTypes.string,
disabled: PropTypes.bool,
};
Expand All @@ -342,6 +382,9 @@ const Ordering = ({
locked = [],
allowsToggle = true,
}) => {
// Track active item by stable id, not by index or object reference.
const activeItemIdRef = useRef(null);
const [, forceUpdate] = useState({});
const handleToggle = (item) => {
const newValue = value.map((e) => {
if (e.id === item) {
Expand All @@ -365,6 +408,13 @@ const Ordering = ({
onUpdate(updatedValue);
};

const handleMove = (fromIndex, toIndex) => {
const newValue = [...value];
const [movedItem] = newValue.splice(fromIndex, 1);
newValue.splice(toIndex, 0, movedItem);
// Active id persists because item.id does not change.
handleChange(newValue);
};
value = value.filter((element) => {
return components.hasOwnProperty(element.id);
});
Expand All @@ -386,14 +436,26 @@ const Ordering = ({
.filter((element) => {
return components.hasOwnProperty(element.id);
})
.map((item) => (
.map((item, index) => (
<Item
locked={locked.includes(item.id)}
key={item.id}
item={item}
onToggle={handleToggle}
allowsToggle={allowsToggle}
components={components}
itemIndex={index}
totalItems={value.length}
onMove={handleMove}
isKeyboardActive={
activeItemIdRef.current === item.id
}
onKeyboardActiveChange={(active) => {
activeItemIdRef.current = active
? item.id
: null;
forceUpdate({});
}}
/>
))}
</ReactSortable>
Expand Down
59 changes: 49 additions & 10 deletions assets/apps/customizer-controls/src/repeater/Repeater.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { Button } from '@wordpress/components';
import { ReactSortable } from 'react-sortablejs';
import { __ } from '@wordpress/i18n';
import { useRef, useState } from '@wordpress/element';

const Repeater = ({
label,
Expand All @@ -15,6 +16,20 @@ const Repeater = ({
}) => {
const itemFields =
Object.keys(newItemFields).length > 0 ? newItemFields : fields;
// Track active item by a stable key (slug or generated) independent of index.
const activeItemKeyRef = useRef(null);
const [, forceUpdate] = useState({});

// Ensure each existing item has a stable internal key for keyboard tracking (preserved across moves).
value.forEach((obj) => {
if (!obj.__kbKey) {
obj.__kbKey =
obj.slug ||
'kb-' +
Date.now().toString(36) +
Math.random().toString(36).slice(2, 8);
}
});

const handleToggle = (index) => {
const newValue = [...value];
Expand Down Expand Up @@ -56,6 +71,12 @@ const Repeater = ({
newItem[field] = itemFields[field].default;
}

// Assign stable key before pushing so initial activation works.
newItem.__kbKey =
newItem.slug ||
'kb-' +
Date.now().toString(36) +
Math.random().toString(36).slice(2, 8);
newValue.push(newItem);
onUpdate(newValue);
};
Expand All @@ -72,18 +93,24 @@ const Repeater = ({
onUpdate(newValue);
};

const handleMove = (fromIndex, toIndex) => {
const newValue = [...value];
const [movedItem] = newValue.splice(fromIndex, 1);
newValue.splice(toIndex, 0, movedItem);
setList(newValue);
};
const setList = (l) => {
const allowed = [
...Object.keys(itemFields),
'title',
'visibility',
'blocked',
'slug',
'__kbKey', // preserve stable keyboard key
];
const final = l.map((i) => {
Object.keys(i).forEach((k) => {
if (
![
...Object.keys(itemFields),
'title',
'visibility',
'blocked',
'slug',
].includes(k)
) {
if (!allowed.includes(k)) {
delete i[k];
}
});
Expand Down Expand Up @@ -112,20 +139,32 @@ const Repeater = ({
handle=".handle"
>
{value.map((val, index) => {
const reactKey = val.__kbKey;
return (
<RepeaterItem
className="nv-repeater-item"
newItemFields={newItemFields}
fields={fields}
value={value}
itemIndex={index}
key={'repeater-item-' + (val.slug || index)}
key={'repeater-item-' + reactKey}
onToggle={handleToggle}
onContentChange={(newItemValue) =>
handleContentChange(index, newItemValue)
}
onRemove={handleRemove}
index={index}
totalItems={value.length}
onMove={handleMove}
isKeyboardActive={
activeItemKeyRef.current === val.__kbKey
}
onKeyboardActiveChange={(active) => {
activeItemKeyRef.current = active
? val.__kbKey
: null;
forceUpdate({});
}}
/>
);
})}
Expand Down
Loading
Loading