Rewrite Features config drag-drop with pointer/touch events

Replace HTML5 Drag and Drop API with pointer/touch event handling
for better mobile support:

- Add pointer event handlers for desktop/stylus drag
- Add global touch event listeners for mobile drag
- Track drag state with refs for consistent behavior
- Calculate drop position based on Y coordinate
- Improve Cancel to restore from initial snapshot

Also improve SwipeTabs to ignore interactive elements (buttons,
links, inputs) during swipe detection, and add touch-action: none
to order cards for proper touch handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 18:41:25 +01:00
parent 6adcf75ef1
commit 1ff1103c67
3 changed files with 337 additions and 92 deletions

View File

@@ -35,7 +35,15 @@ const findTouchById = (touches: React.TouchList, id: number): React.Touch | null
return touch;
}
}
return null;
return null;
};
const resolveSwipeTarget = (target: EventTarget | null): Element | null => {
if (!target) return null;
const element = target as Element;
if (typeof element.closest === 'function') return element;
const node = target as Node & { parentElement?: Element | null };
return node.parentElement ?? null;
};
export function SwipeTabs<T extends string | number>({
@@ -80,8 +88,18 @@ export function SwipeTabs<T extends string | number>({
});
const shouldIgnoreSwipe = useCallback((target: EventTarget | null) => {
if (!target || typeof (target as Element).closest !== 'function') return false;
return Boolean((target as Element).closest('[data-swipe-ignore="true"]'));
const element = resolveSwipeTarget(target);
if (!element) return false;
if (element.closest(
'[data-swipe-ignore="true"], button, a, input, select, textarea, [role="button"], [role="link"], [role="switch"], [contenteditable="true"]'
)) {
return true;
}
const label = element.closest('label');
if (label && label.querySelector('input, select, textarea, button')) {
return true;
}
return false;
}, []);
const applyTransform = useCallback((offset: number, animate: boolean) => {

View File

@@ -9,6 +9,17 @@ import { SwipeTabs } from '../../components/SwipeTabs';
import '../../styles/AdminPanel.css';
type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
const ORDER_DRAG_THRESHOLD = 4;
const findTouchById = (touches: TouchList, id: number) => {
for (let i = 0; i < touches.length; i += 1) {
const touch = touches.item(i);
if (touch && touch.identifier === id) {
return touch;
}
}
return null;
};
export default function Features() {
const { user: currentUser } = useAuth();
@@ -26,6 +37,27 @@ export default function Features() {
const isUserEditing = useRef(false); // Track if user is actively editing
const initialOrderRef = useRef<string[]>([]);
const initialPositionsRef = useRef<Record<string, 'top' | 'bottom'>>({});
const topOrderRef = useRef<HTMLDivElement>(null);
const bottomOrderRef = useRef<HTMLDivElement>(null);
const localOrderRef = useRef<string[]>([]);
const localPositionsRef = useRef<Record<string, 'top' | 'bottom'>>({});
const dragPointerRef = useRef({
pointerId: null as number | null,
touchId: null as number | null,
activeId: null as string | null,
startX: 0,
startY: 0,
currentY: 0,
isDragging: false,
source: null as 'pointer' | 'touch' | null
});
const globalListenersRef = useRef({ touch: false });
const touchHandlersRef = useRef({
move: (_event: TouchEvent) => {},
end: (_event: TouchEvent) => {},
cancel: (_event: TouchEvent) => {}
});
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
const buildFullOrder = useCallback((order: string[]) => {
const fullOrder = [...order];
@@ -86,6 +118,14 @@ export default function Features() {
}
}, [buildPositionsFromStates]);
useEffect(() => {
localOrderRef.current = localOrder;
}, [localOrder]);
useEffect(() => {
localPositionsRef.current = localPositions;
}, [localPositions]);
// Scroll active tab to center of container
const scrollActiveTabIntoView = useCallback((tabId: string) => {
if (tabsContainerRef.current) {
@@ -186,79 +226,248 @@ export default function Features() {
);
};
// Drag and drop handlers for ordering
const handleDragStart = (e: React.DragEvent, moduleId: string) => {
const getSectionForY = useCallback((y: number): 'top' | 'bottom' => {
const topRect = topOrderRef.current?.getBoundingClientRect();
const bottomRect = bottomOrderRef.current?.getBoundingClientRect();
const inTop = topRect && y >= topRect.top && y <= topRect.bottom;
const inBottom = bottomRect && y >= bottomRect.top && y <= bottomRect.bottom;
if (inTop) return 'top';
if (inBottom) return 'bottom';
if (topRect && bottomRect) {
const topDistance = Math.abs(y - (topRect.top + topRect.bottom) / 2);
const bottomDistance = Math.abs(y - (bottomRect.top + bottomRect.bottom) / 2);
return topDistance <= bottomDistance ? 'top' : 'bottom';
}
return topRect ? 'top' : 'bottom';
}, []);
const getInsertIndex = useCallback((section: 'top' | 'bottom', y: number, draggedId: string) => {
const container = section === 'top' ? topOrderRef.current : bottomOrderRef.current;
if (!container) return 0;
const cards = Array.from(container.querySelectorAll<HTMLElement>('[data-module-id]'))
.filter(card => card.dataset.moduleId !== draggedId);
for (let i = 0; i < cards.length; i += 1) {
const rect = cards[i].getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
if (y < midpoint) return i;
}
return cards.length;
}, []);
const resetPointerDrag = useCallback(() => {
dragPointerRef.current.pointerId = null;
dragPointerRef.current.touchId = null;
dragPointerRef.current.activeId = null;
dragPointerRef.current.startX = 0;
dragPointerRef.current.startY = 0;
dragPointerRef.current.currentY = 0;
dragPointerRef.current.isDragging = false;
dragPointerRef.current.source = null;
}, []);
const applyReorder = useCallback((activeId: string, dropY: number) => {
const targetSection = getSectionForY(dropY);
const insertIndex = getInsertIndex(targetSection, dropY, activeId);
const baseOrder = localOrderRef.current.length
? localOrderRef.current
: buildFullOrder(moduleOrder);
const basePositions = Object.keys(localPositionsRef.current).length
? localPositionsRef.current
: buildPositionsFromStates();
const nextPositions = { ...basePositions, [activeId]: targetSection };
const remaining = baseOrder.filter(id => id !== activeId);
const topList = remaining.filter(id => nextPositions[id] !== 'bottom');
const bottomList = remaining.filter(id => nextPositions[id] === 'bottom');
const targetList = targetSection === 'top' ? topList : bottomList;
const clampedIndex = Math.max(0, Math.min(insertIndex, targetList.length));
targetList.splice(clampedIndex, 0, activeId);
const nextOrder = targetSection === 'top'
? [...targetList, ...bottomList]
: [...topList, ...targetList];
const orderChanged = nextOrder.length !== baseOrder.length
|| nextOrder.some((id, index) => id !== baseOrder[index]);
const previousPosition = basePositions[activeId] || 'top';
const positionChanged = previousPosition !== targetSection;
if (orderChanged || positionChanged) {
localOrderRef.current = nextOrder;
localPositionsRef.current = nextPositions;
setLocalPositions(nextPositions);
setLocalOrder(nextOrder);
setHasOrderChanges(true);
}
}, [buildFullOrder, buildPositionsFromStates, getInsertIndex, getSectionForY, moduleOrder]);
const handleOrderPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const dragState = dragPointerRef.current;
if (dragState.pointerId !== event.pointerId || !dragState.activeId || dragState.source !== 'pointer') return;
dragState.currentY = event.clientY;
const dx = event.clientX - dragState.startX;
const dy = event.clientY - dragState.startY;
if (!dragState.isDragging) {
if (Math.abs(dx) < ORDER_DRAG_THRESHOLD && Math.abs(dy) < ORDER_DRAG_THRESHOLD) {
return;
}
dragState.isDragging = true;
setDraggedItem(dragState.activeId);
}
if (dragState.isDragging) {
applyReorder(dragState.activeId, dragState.currentY);
}
if (dragState.isDragging && event.pointerType !== 'mouse' && event.cancelable) {
event.preventDefault();
}
}, [applyReorder]);
const handleOrderPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const dragState = dragPointerRef.current;
if (dragState.pointerId !== event.pointerId || dragState.source !== 'pointer') return;
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const activeId = dragState.activeId;
const wasDragging = dragState.isDragging;
const dropY = dragState.currentY;
resetPointerDrag();
setDraggedItem(null);
if (!activeId || !wasDragging) return;
applyReorder(activeId, dropY);
}, [applyReorder, resetPointerDrag]);
const handleOrderPointerCancel = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const dragState = dragPointerRef.current;
if (dragState.pointerId !== event.pointerId || dragState.source !== 'pointer') return;
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
resetPointerDrag();
setDraggedItem(null);
}, [resetPointerDrag]);
const addTouchListeners = useCallback(() => {
if (typeof window === 'undefined' || globalListenersRef.current.touch) return;
window.addEventListener('touchmove', touchHandlersRef.current.move, { passive: false });
window.addEventListener('touchend', touchHandlersRef.current.end, { passive: false });
window.addEventListener('touchcancel', touchHandlersRef.current.cancel, { passive: false });
globalListenersRef.current.touch = true;
}, []);
const removeTouchListeners = useCallback(() => {
if (typeof window === 'undefined' || !globalListenersRef.current.touch) return;
window.removeEventListener('touchmove', touchHandlersRef.current.move);
window.removeEventListener('touchend', touchHandlersRef.current.end);
window.removeEventListener('touchcancel', touchHandlersRef.current.cancel);
globalListenersRef.current.touch = false;
}, []);
const handleOrderPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>, moduleId: string) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
beginOrderEdit();
setDraggedItem(moduleId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', moduleId);
};
dragPointerRef.current.pointerId = event.pointerId;
dragPointerRef.current.touchId = null;
dragPointerRef.current.activeId = moduleId;
dragPointerRef.current.startX = event.clientX;
dragPointerRef.current.startY = event.clientY;
dragPointerRef.current.currentY = event.clientY;
dragPointerRef.current.isDragging = false;
dragPointerRef.current.source = 'pointer';
if (event.currentTarget.setPointerCapture) {
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Ignore pointer capture failures.
}
}
}, [beginOrderEdit]);
const handleDragEnd = () => {
const handleGlobalTouchMove = useCallback((event: TouchEvent) => {
const dragState = dragPointerRef.current;
if (dragState.source !== 'touch' || dragState.touchId === null || !dragState.activeId) return;
const touch = findTouchById(event.changedTouches, dragState.touchId);
if (!touch) return;
dragState.currentY = touch.clientY;
const dx = touch.clientX - dragState.startX;
const dy = touch.clientY - dragState.startY;
if (!dragState.isDragging) {
if (Math.abs(dx) < ORDER_DRAG_THRESHOLD && Math.abs(dy) < ORDER_DRAG_THRESHOLD) {
return;
}
dragState.isDragging = true;
setDraggedItem(dragState.activeId);
}
if (dragState.isDragging) {
applyReorder(dragState.activeId, dragState.currentY);
}
if (dragState.isDragging && event.cancelable) {
event.preventDefault();
}
}, [applyReorder]);
const handleGlobalTouchEnd = useCallback((event: TouchEvent) => {
const dragState = dragPointerRef.current;
if (dragState.source !== 'touch' || dragState.touchId === null) return;
const touch = findTouchById(event.changedTouches, dragState.touchId);
if (!touch) return;
const activeId = dragState.activeId;
const wasDragging = dragState.isDragging;
const dropY = dragState.currentY;
removeTouchListeners();
resetPointerDrag();
setDraggedItem(null);
};
if (!activeId || !wasDragging) return;
applyReorder(activeId, dropY);
}, [applyReorder, removeTouchListeners, resetPointerDrag]);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent, targetModuleId: string, targetSection: 'top' | 'bottom') => {
e.preventDefault();
e.stopPropagation();
if (!draggedItem) return;
const draggedPosition = localPositions[draggedItem] || 'top';
const isSameItem = draggedItem === targetModuleId;
const positionChanged = draggedPosition !== targetSection;
if (isSameItem && !positionChanged) return;
const handleGlobalTouchCancel = useCallback((event: TouchEvent) => {
const dragState = dragPointerRef.current;
if (dragState.source !== 'touch' || dragState.touchId === null) return;
const touch = findTouchById(event.changedTouches, dragState.touchId);
if (!touch) return;
removeTouchListeners();
resetPointerDrag();
setDraggedItem(null);
}, [removeTouchListeners, resetPointerDrag]);
const handleOrderTouchStart = useCallback((event: React.TouchEvent<HTMLDivElement>, moduleId: string) => {
if (event.changedTouches.length === 0) return;
beginOrderEdit();
if (positionChanged) {
// Update local positions immediately for UI responsiveness
setLocalPositions(prev => ({ ...prev, [draggedItem]: targetSection }));
}
if (isSameItem) {
setHasOrderChanges(true);
return;
}
setDraggedItem(moduleId);
const touch = event.changedTouches[0];
dragPointerRef.current.pointerId = null;
dragPointerRef.current.touchId = touch.identifier;
dragPointerRef.current.activeId = moduleId;
dragPointerRef.current.startX = touch.clientX;
dragPointerRef.current.startY = touch.clientY;
dragPointerRef.current.currentY = touch.clientY;
dragPointerRef.current.isDragging = false;
dragPointerRef.current.source = 'touch';
addTouchListeners();
}, [addTouchListeners, beginOrderEdit]);
// Reorder within the list
const newOrder = [...localOrder];
const draggedIndex = newOrder.indexOf(draggedItem);
const targetIndex = newOrder.indexOf(targetModuleId);
useEffect(() => {
touchHandlersRef.current = {
move: handleGlobalTouchMove,
end: handleGlobalTouchEnd,
cancel: handleGlobalTouchCancel
};
}, [handleGlobalTouchCancel, handleGlobalTouchEnd, handleGlobalTouchMove]);
if (draggedIndex === -1 || targetIndex === -1) return;
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedItem);
setLocalOrder(newOrder);
setHasOrderChanges(true);
};
const handleSectionDrop = (e: React.DragEvent, section: 'top' | 'bottom') => {
e.preventDefault();
if (!draggedItem) return;
const draggedPosition = localPositions[draggedItem] || 'top';
// Change position if moving to different section
if (draggedPosition !== section) {
beginOrderEdit();
// Update local positions immediately for UI responsiveness
setLocalPositions(prev => ({ ...prev, [draggedItem]: section }));
setHasOrderChanges(true);
}
};
useEffect(() => {
return () => {
removeTouchListeners();
};
}, [removeTouchListeners]);
const handleApplyOrder = async () => {
const orderToApply = localOrderRef.current.length ? localOrderRef.current : localOrder;
const positionsToApply = Object.keys(localPositionsRef.current).length
? localPositionsRef.current
: localPositions;
try {
// Update positions in context first
const positionUpdates: Array<{ id: ModuleId; position: 'top' | 'bottom' }> = [];
TOGGLEABLE_MODULES.forEach(module => {
const desiredPosition = localPositions[module.id] || module.defaultPosition;
const desiredPosition = positionsToApply[module.id] || module.defaultPosition;
const currentPosition = moduleStates[module.id]?.position || module.defaultPosition;
if (desiredPosition !== currentPosition) {
positionUpdates.push({ id: module.id, position: desiredPosition });
@@ -266,35 +475,52 @@ export default function Features() {
});
if (positionUpdates.length > 0) {
hasUserMadeChanges.current = true;
positionUpdates.forEach(({ id, position }) => {
setModulePosition(id, position);
});
}
setModuleOrder(localOrder);
await saveModuleOrder(localOrder);
// Update order in context
setModuleOrder(orderToApply);
// Save both order and positions to backend immediately
await saveModuleOrder(orderToApply);
// Save positions explicitly (don't rely on debounced effect)
if (positionUpdates.length > 0) {
await saveModulesToBackend();
}
} catch (error) {
console.error('Failed to save order:', error);
} finally {
setDraggedItem(null); // Reset drag state
initialOrderRef.current = buildFullOrder(localOrder);
initialPositionsRef.current = normalizePositions(localPositions);
setDraggedItem(null);
localOrderRef.current = orderToApply;
localPositionsRef.current = positionsToApply;
initialOrderRef.current = buildFullOrder(orderToApply);
initialPositionsRef.current = normalizePositions(positionsToApply);
isUserEditing.current = false;
setHasOrderChanges(false);
}
};
const handleCancelOrder = () => {
setDraggedItem(null); // Reset drag state
isUserEditing.current = false; // Done editing
// Restore to currently applied state from context
const restoredOrder = buildFullOrder(moduleOrder);
const restoredPositions = buildPositionsFromStates();
setLocalOrder(restoredOrder);
setLocalPositions(restoredPositions);
initialOrderRef.current = restoredOrder;
initialPositionsRef.current = restoredPositions;
// Reset drag state
setDraggedItem(null);
isUserEditing.current = false;
const restoredOrder = initialOrderRef.current.length
? [...initialOrderRef.current]
: buildFullOrder(moduleOrder);
const restoredPositions = Object.keys(initialPositionsRef.current).length
? { ...normalizePositions(initialPositionsRef.current) }
: buildPositionsFromStates();
localOrderRef.current = restoredOrder;
localPositionsRef.current = restoredPositions;
setLocalOrder([...restoredOrder]);
setLocalPositions({ ...restoredPositions });
initialOrderRef.current = [...restoredOrder];
initialPositionsRef.current = { ...restoredPositions };
setHasOrderChanges(false);
};
@@ -339,8 +565,7 @@ export default function Features() {
<div
className="order-cards"
data-swipe-ignore="true"
onDragOver={handleDragOver}
onDrop={(e) => handleSectionDrop(e, 'top')}
ref={topOrderRef}
>
{topOrderModules.map((moduleId) => {
const moduleInfo = getModuleInfo(moduleId);
@@ -349,11 +574,12 @@ export default function Features() {
<div
key={moduleId}
className={`order-card ${draggedItem === moduleId ? 'dragging' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, moduleId)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, moduleId, 'top')}
data-module-id={moduleId}
onPointerDown={supportsPointerEvents ? (e) => handleOrderPointerDown(e, moduleId) : undefined}
onPointerMove={supportsPointerEvents ? handleOrderPointerMove : undefined}
onPointerUp={supportsPointerEvents ? handleOrderPointerUp : undefined}
onPointerCancel={supportsPointerEvents ? handleOrderPointerCancel : undefined}
onTouchStart={!supportsPointerEvents ? (e) => handleOrderTouchStart(e, moduleId) : undefined}
>
<div className="order-card-preview">
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
@@ -380,8 +606,7 @@ export default function Features() {
<div
className="order-cards"
data-swipe-ignore="true"
onDragOver={handleDragOver}
onDrop={(e) => handleSectionDrop(e, 'bottom')}
ref={bottomOrderRef}
>
{bottomOrderModules.map((moduleId) => {
const moduleInfo = getModuleInfo(moduleId);
@@ -390,11 +615,12 @@ export default function Features() {
<div
key={moduleId}
className={`order-card ${draggedItem === moduleId ? 'dragging' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, moduleId)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, moduleId, 'bottom')}
data-module-id={moduleId}
onPointerDown={supportsPointerEvents ? (e) => handleOrderPointerDown(e, moduleId) : undefined}
onPointerMove={supportsPointerEvents ? handleOrderPointerMove : undefined}
onPointerUp={supportsPointerEvents ? handleOrderPointerUp : undefined}
onPointerCancel={supportsPointerEvents ? handleOrderPointerCancel : undefined}
onTouchStart={!supportsPointerEvents ? (e) => handleOrderTouchStart(e, moduleId) : undefined}
>
<div className="order-card-preview">
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
@@ -415,7 +641,7 @@ export default function Features() {
</div>
{hasOrderChanges && (
<div className="order-actions">
<div className="order-actions" data-swipe-ignore="true">
<button className="btn-secondary" onClick={handleCancelOrder}>
<span className="material-symbols-outlined">close</span>
{t.featuresPage?.cancelOrder || 'Annulla'}

View File

@@ -2502,6 +2502,7 @@
cursor: grab;
transition: all 0.25s ease;
user-select: none;
touch-action: none;
}
.order-card:hover {