From 48d85231224078a0e26294c127171c10a8cfeffe Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:36:09 +1000 Subject: [PATCH] create sortable list component --- ui/src/components/SortableList.tsx | 311 ++++++++++++++++ ui/src/index.css | 43 --- ui/src/routes/devices.$id.settings.macros.tsx | 338 ++++-------------- 3 files changed, 380 insertions(+), 312 deletions(-) create mode 100644 ui/src/components/SortableList.tsx diff --git a/ui/src/components/SortableList.tsx b/ui/src/components/SortableList.tsx new file mode 100644 index 0000000..9cd3669 --- /dev/null +++ b/ui/src/components/SortableList.tsx @@ -0,0 +1,311 @@ +import { useState, useCallback, ReactNode } from 'react'; +import clsx from 'clsx'; +import { LuGripVertical } from 'react-icons/lu'; +import { cva } from "@/cva.config"; + +interface SortableListProps { + items: T[]; + keyFn: (item: T) => string; + onSort: (newItems: T[]) => Promise; + children: (item: T, index: number) => ReactNode; + disabled?: boolean; + className?: string; + itemClassName?: string; + variant?: 'list' | 'grid'; + size?: keyof typeof sizes; + renderHandle?: (isDragging: boolean) => ReactNode; + hideHandle?: boolean; + handlePosition?: 'left' | 'right'; +} + +const sizes = { + XS: "min-h-[24.5px] py-1 px-3 text-xs", + SM: "min-h-[32px] py-1.5 px-3 text-[13px]", + MD: "min-h-[40px] py-2 px-4 text-sm", + LG: "min-h-[48px] py-2.5 px-4 text-base", +}; + +const containerVariants = { + list: { + XS: 'space-y-1', + SM: 'space-y-2', + MD: 'space-y-3', + LG: 'space-y-4' + }, + grid: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4', +}; + +const sortableItemVariants = cva({ + base: 'transition-all duration-300 ease-out rounded', + variants: { + size: sizes, + isDragging: { + true: 'shadow-lg bg-blue-100/80 dark:bg-blue-900/40 border-blue-200 dark:border-blue-800 z-50', + false: '' + }, + isDropTarget: { + true: 'border border-dashed border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/10', + false: '' + }, + handlePosition: { + left: 'flex-row', + right: 'flex-row-reverse' + }, + disabled: { + true: 'pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80', + false: 'hover:bg-blue-50/80 active:bg-blue-100/60 dark:hover:bg-slate-700 dark:active:bg-slate-800/60' + } + }, + defaultVariants: { + size: 'MD', + isDragging: false, + isDropTarget: false, + handlePosition: 'left', + disabled: false + } +}); + +const DefaultHandle = ({ isDragging, disabled }: { isDragging: boolean; disabled?: boolean }) => ( +
+ +
+); + +export function SortableList({ + items, + keyFn, + onSort, + children, + disabled = false, + className = '', + itemClassName = '', + variant = 'list', + size = 'MD', + renderHandle, + hideHandle = false, + handlePosition = 'left', +}: SortableListProps) { + const [dragItem, setDragItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + const [touchStartY, setTouchStartY] = useState(null); + + const containerClasses = clsx( + 'sortable-list', + variant === 'grid' ? containerVariants.grid : containerVariants.list[size], + className + ); + + const getItemClasses = (index: number) => clsx( + 'relative flex items-center', + sortableItemVariants({ + size, + isDragging: dragItem === index, + isDropTarget: dragOverItem === index && dragItem !== index, + handlePosition, + disabled + }), + itemClassName + ); + + const handleDragStart = useCallback((index: number) => { + if (disabled) return; + setDragItem(index); + + const allItems = document.querySelectorAll('[data-sortable-item]'); + const draggedElement = allItems[index]; + if (draggedElement) { + draggedElement.classList.add('dragging'); + } + }, [disabled]); + + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { + if (disabled) return; + e.preventDefault(); + setDragOverItem(index); + + const allItems = document.querySelectorAll('[data-sortable-item]'); + allItems.forEach(el => el.classList.remove('drop-target')); + + const targetElement = allItems[index]; + if (targetElement) { + targetElement.classList.add('drop-target'); + } + }, [disabled]); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + if (disabled) return; + e.preventDefault(); + if (dragItem === null || dragOverItem === null) return; + + const itemsCopy = [...items]; + const draggedItem = itemsCopy.splice(dragItem, 1)[0]; + itemsCopy.splice(dragOverItem, 0, draggedItem); + + await onSort(itemsCopy); + + const allItems = document.querySelectorAll('[data-sortable-item]'); + allItems.forEach(el => { + el.classList.remove('drop-target'); + el.classList.remove('dragging'); + }); + + setDragItem(null); + setDragOverItem(null); + }, [disabled, dragItem, dragOverItem, items, onSort]); + + const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => { + if (disabled) return; + const touch = e.touches[0]; + setTouchStartY(touch.clientY); + setDragItem(index); + + const element = e.currentTarget as HTMLElement; + const rect = element.getBoundingClientRect(); + + // Create ghost element + const ghost = element.cloneNode(true) as HTMLElement; + ghost.id = 'ghost-item'; + ghost.className = 'sortable-ghost'; + ghost.style.height = `${rect.height}px`; + element.parentNode?.insertBefore(ghost, element); + + // Set up dragged element + element.style.position = 'fixed'; + element.style.left = `${rect.left}px`; + element.style.top = `${rect.top}px`; + element.style.width = `${rect.width}px`; + element.style.zIndex = '50'; + }, [disabled]); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (disabled || touchStartY === null || dragItem === null) return; + + const touch = e.touches[0]; + const deltaY = touch.clientY - touchStartY; + const element = e.currentTarget as HTMLElement; + + element.style.transform = `translateY(${deltaY}px)`; + + const sortableElements = document.querySelectorAll('[data-sortable-item]'); + const draggedRect = element.getBoundingClientRect(); + const draggedMiddle = draggedRect.top + draggedRect.height / 2; + + sortableElements.forEach((el, i) => { + if (i === dragItem) return; + + const rect = el.getBoundingClientRect(); + const elementMiddle = rect.top + rect.height / 2; + const distance = Math.abs(draggedMiddle - elementMiddle); + + if (distance < rect.height) { + const direction = draggedMiddle > elementMiddle ? -1 : 1; + (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`; + (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; + } else { + (el as HTMLElement).style.transform = ''; + (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; + } + }); + }, [disabled, touchStartY, dragItem]); + + const handleTouchEnd = useCallback(async (e: React.TouchEvent) => { + if (disabled || dragItem === null) return; + + const element = e.currentTarget as HTMLElement; + const touch = e.changedTouches[0]; + + // Remove ghost element + const ghost = document.getElementById('ghost-item'); + ghost?.parentNode?.removeChild(ghost); + + // Reset dragged element styles + element.style.position = ''; + element.style.left = ''; + element.style.top = ''; + element.style.width = ''; + element.style.zIndex = ''; + element.style.transform = ''; + element.style.transition = ''; + + const sortableElements = document.querySelectorAll('[data-sortable-item]'); + let targetIndex = dragItem; + + // Find the closest element to the final touch position + const finalY = touch.clientY; + let closestDistance = Infinity; + + sortableElements.forEach((el, i) => { + if (i === dragItem) return; + + const rect = el.getBoundingClientRect(); + const distance = Math.abs(finalY - (rect.top + rect.height / 2)); + + if (distance < closestDistance) { + closestDistance = distance; + targetIndex = i; + } + + // Reset other elements + (el as HTMLElement).style.transform = ''; + (el as HTMLElement).style.transition = ''; + }); + + if (targetIndex !== dragItem && closestDistance < 50) { + const itemsCopy = [...items]; + const [draggedItem] = itemsCopy.splice(dragItem, 1); + itemsCopy.splice(targetIndex, 0, draggedItem); + await onSort(itemsCopy); + } + + setTouchStartY(null); + setDragItem(null); + }, [disabled, dragItem, items, onSort]); + + return ( +
+ {items.map((item, index) => ( +
handleDragStart(index)} + onDragOver={e => handleDragOver(e, index)} + onDragEnd={() => { + const allItems = document.querySelectorAll('[data-sortable-item]'); + allItems.forEach(el => { + el.classList.remove('drop-target'); + el.classList.remove('dragging'); + }); + }} + onDrop={handleDrop} + onTouchStart={e => handleTouchStart(e, index)} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + className={getItemClasses(index)} + > +
+ {handlePosition === 'left' && !hideHandle && ( + renderHandle ? + renderHandle(dragItem === index) : + + )} +
+ {children(item, index)} +
+ {handlePosition === 'right' && !hideHandle && ( + renderHandle ? + renderHandle(dragItem === index) : + + )} +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/ui/src/index.css b/ui/src/index.css index c7865e0..5052657 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -201,46 +201,3 @@ video::-webkit-media-controls { .hide-scrollbar::-webkit-scrollbar { display: none; } - -/* Macro list sortable */ -.macro-sortable, [data-macro-item] { - transition: box-shadow 0.15s ease-out, background-color 0.2s ease-out, transform 0.1s, border-color 0.2s; - position: relative; - touch-action: none; -} - -.macro-sortable.dragging, [data-macro-item].dragging { - z-index: 10; - opacity: 0.8; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - @apply bg-blue-500/10; -} - -.macro-sortable.drop-target, [data-macro-item].drop-target { - @apply border-2 border-dashed border-blue-500 bg-blue-500/5; -} - -.macro-sortable.ghost { - position: static; - opacity: 0.3; - pointer-events: none; - background-color: transparent; - border: 2px dashed rgb(148 163 184); - transform: none; -} - -.drag-handle, .macro-sortable-handle { - cursor: grab; - touch-action: none; - @apply flex items-center p-1 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300; -} - -.drag-handle:active, .macro-sortable-handle:active { - cursor: grabbing; -} - -@media (hover: none) { - .macro-sortable, [data-macro-item] { - user-select: none; - } -} diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index 583fa00..6dc4a93 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useRef, useCallback, Fragment } from "react"; -import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuGripVertical, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; +import { useState, useEffect, useCallback, Fragment } from "react"; +import { LuPlus, LuTrash, LuX, LuPenLine, LuLoader, LuInfo, LuCopy, LuArrowUp, LuArrowDown, LuMoveRight, LuCornerDownRight } from "react-icons/lu"; import { KeySequence, useMacrosStore } from "@/hooks/stores"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; @@ -16,6 +16,7 @@ import EmptyCard from "@/components/EmptyCard"; import { Combobox } from "@/components/Combobox"; import { CardHeader } from "@/components/CardHeader"; import Card from "@/components/Card"; +import { SortableList } from "@/components/SortableList"; const DEFAULT_DELAY = 50; @@ -302,122 +303,6 @@ const updateStepKeys = ( return newSteps; }; -const useTouchSort = (items: KeySequence[], onSort: (newItems: KeySequence[]) => void) => { - const [touchStartY, setTouchStartY] = useState(null); - const [touchedIndex, setTouchedIndex] = useState(null); - - const handleTouchStart = useCallback((e: React.TouchEvent, index: number) => { - const touch = e.touches[0]; - setTouchStartY(touch.clientY); - setTouchedIndex(index); - - const element = e.currentTarget as HTMLElement; - const rect = element.getBoundingClientRect(); - - // Create ghost element - const ghost = element.cloneNode(true) as HTMLElement; - ghost.id = 'ghost-macro'; - ghost.className = 'macro-sortable ghost'; - ghost.style.height = `${rect.height}px`; - element.parentNode?.insertBefore(ghost, element); - - // Set up dragged element - element.style.position = 'fixed'; - element.style.left = `${rect.left}px`; - element.style.top = `${rect.top}px`; - element.style.width = `${rect.width}px`; - element.style.zIndex = '50'; - }, []); - - const handleTouchMove = useCallback((e: React.TouchEvent) => { - if (touchStartY === null || touchedIndex === null) return; - - const touch = e.touches[0]; - const deltaY = touch.clientY - touchStartY; - const element = e.currentTarget as HTMLElement; - - // Smooth movement of dragged element - element.style.transform = `translateY(${deltaY}px)`; - - const macroElements = document.querySelectorAll('[data-macro-item]'); - const draggedRect = element.getBoundingClientRect(); - const draggedMiddle = draggedRect.top + draggedRect.height / 2; - - macroElements.forEach((el, i) => { - if (i === touchedIndex) return; - - const rect = el.getBoundingClientRect(); - const elementMiddle = rect.top + rect.height / 2; - const distance = Math.abs(draggedMiddle - elementMiddle); - - if (distance < rect.height) { - const direction = draggedMiddle > elementMiddle ? -1 : 1; - (el as HTMLElement).style.transform = `translateY(${direction * rect.height}px)`; - (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; - } else { - (el as HTMLElement).style.transform = ''; - (el as HTMLElement).style.transition = 'transform 0.15s ease-out'; - } - }); - }, [touchStartY, touchedIndex]); - - const handleTouchEnd = useCallback(async (e: React.TouchEvent) => { - if (touchedIndex === null) return; - - const element = e.currentTarget as HTMLElement; - const touch = e.changedTouches[0]; - - // Remove ghost element - const ghost = document.getElementById('ghost-macro'); - ghost?.parentNode?.removeChild(ghost); - - // Reset dragged element styles - element.style.position = ''; - element.style.left = ''; - element.style.top = ''; - element.style.width = ''; - element.style.zIndex = ''; - element.style.transform = ''; - element.style.boxShadow = ''; - element.style.transition = ''; - - const macroElements = document.querySelectorAll('[data-macro-item]'); - let targetIndex = touchedIndex; - - // Find the closest element to the final touch position - const finalY = touch.clientY; - let closestDistance = Infinity; - - macroElements.forEach((el, i) => { - if (i === touchedIndex) return; - - const rect = el.getBoundingClientRect(); - const distance = Math.abs(finalY - (rect.top + rect.height / 2)); - - if (distance < closestDistance) { - closestDistance = distance; - targetIndex = i; - } - - // Reset other elements - (el as HTMLElement).style.transform = ''; - (el as HTMLElement).style.transition = ''; - }); - - if (targetIndex !== touchedIndex && closestDistance < 50) { - const itemsCopy = [...items]; - const [draggedItem] = itemsCopy.splice(touchedIndex, 1); - itemsCopy.splice(targetIndex, 0, draggedItem); - onSort(itemsCopy); - } - - setTouchStartY(null); - setTouchedIndex(null); - }, [touchedIndex, items, onSort]); - - return { handleTouchStart, handleTouchMove, handleTouchEnd }; -}; - interface StepError { keys?: string; modifiers?: string; @@ -438,10 +323,6 @@ export default function SettingsMacrosRoute() { description: "", steps: [{ keys: [], modifiers: [], delay: DEFAULT_DELAY }], }); - const [isDragging, setIsDragging] = useState(false); - const dragItem = useRef(null); - const dragOverItem = useRef(null); - const [macroToDelete, setMacroToDelete] = useState(null); const [keyQueries, setKeyQueries] = useState>({}); @@ -631,99 +512,6 @@ export default function SettingsMacrosRoute() { } }, [isMaxMacrosReached, newMacro, macros, saveMacros, showTemporaryError]); - const handleDragStart = (index: number) => { - dragItem.current = index; - setIsDragging(true); - - const allItems = document.querySelectorAll('[data-macro-item]'); - const draggedElement = allItems[index]; - if (draggedElement) { - draggedElement.classList.add('dragging'); - } - }; - - const handleDragOver = (e: React.DragEvent, index: number) => { - e.preventDefault(); - dragOverItem.current = index; - - const allItems = document.querySelectorAll('[data-macro-item]'); - allItems.forEach(el => el.classList.remove('drop-target')); - - const targetElement = allItems[index]; - if (targetElement) { - targetElement.classList.add('drop-target'); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault(); - if (dragItem.current === null || dragOverItem.current === null) return; - - const macroCopy = [...macros]; - const draggedItem = macroCopy.splice(dragItem.current, 1)[0]; - macroCopy.splice(dragOverItem.current, 0, draggedItem); - const updatedMacros = normalizeSortOrders(macroCopy); - - try { - await saveMacros(updatedMacros); - notifications.success("Macro order updated successfully"); - } catch (error) { - if (error instanceof Error) { - notifications.error(`Failed to reorder macros: ${error.message}`); - showTemporaryError(error.message); - } else { - notifications.error("Failed to reorder macros"); - showTemporaryError("Failed to save reordered macros"); - } - } - - const allItems = document.querySelectorAll('[data-macro-item]'); - allItems.forEach(el => { - el.classList.remove('drop-target'); - el.classList.remove('dragging'); - }); - - dragItem.current = null; - dragOverItem.current = null; - setIsDragging(false); - }; - - const handleUpdateMacro = useCallback(async () => { - if (!editingMacro) return; - - const validationErrors = validateMacro(editingMacro); - if (Object.keys(validationErrors).length > 0) { - setErrors(validationErrors); - return; - } - - setIsUpdating(true); - try { - const newMacros = macros.map(m => - m.id === editingMacro.id ? { - ...editingMacro, - name: editingMacro.name.trim(), - description: editingMacro.description?.trim() || "", - } : m - ); - - await saveMacros(normalizeSortOrders(newMacros)); - setEditingMacro(null); - clearErrors(); - notifications.success(`Macro "${editingMacro.name}" updated successfully`); - } catch (error) { - if (error instanceof Error) { - notifications.error(`Failed to update macro: ${error.message}`); - showTemporaryError(error.message); - } else { - notifications.error("Failed to update macro"); - showTemporaryError("Failed to update macro"); - } - } finally { - setIsUpdating(false); - } - }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]); - const handleEditMacro = (macro: KeySequence) => { setEditingMacro({ ...macro, @@ -815,28 +603,45 @@ export default function SettingsMacrosRoute() { return () => window.removeEventListener('resize', handleResize); }, []); - const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSort( - macros, - async (newMacros) => { - const updatedMacros = normalizeSortOrders(newMacros); - try { - await saveMacros(updatedMacros); - notifications.success("Macro order updated successfully"); - } catch (error) { - if (error instanceof Error) { - notifications.error(`Failed to reorder macros: ${error.message}`); - showTemporaryError(error.message); - } else { - notifications.error("Failed to reorder macros"); - showTemporaryError("Failed to save reordered macros"); - } - } - } - ); - const [showClearConfirm, setShowClearConfirm] = useState(false); const [showAddMacro, setShowAddMacro] = useState(false); + const handleUpdateMacro = useCallback(async () => { + if (!editingMacro) return; + + const validationErrors = validateMacro(editingMacro); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + setIsUpdating(true); + try { + const newMacros = macros.map(m => + m.id === editingMacro.id ? { + ...editingMacro, + name: editingMacro.name.trim(), + description: editingMacro.description?.trim() || "", + } : m + ); + + await saveMacros(normalizeSortOrders(newMacros)); + setEditingMacro(null); + clearErrors(); + notifications.success(`Macro "${editingMacro.name}" updated successfully`); + } catch (error) { + if (error instanceof Error) { + notifications.error(`Failed to update macro: ${error.message}`); + showTemporaryError(error.message); + } else { + notifications.error("Failed to update macro"); + showTemporaryError("Failed to update macro"); + } + } finally { + setIsUpdating(false); + } + }, [editingMacro, macros, saveMacros, showTemporaryError, clearErrors]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && editingMacro) { @@ -886,7 +691,6 @@ export default function SettingsMacrosRoute() { ); }; - return (
)} {macros.length > 0 && ( -
- {macros.map((macro, index) => + + keyFn={(macro) => macro.id} + items={macros} + itemClassName="rounded-md border border-slate-200 p-2 dark:border-slate-700 bg-white dark:bg-slate-800" + onSort={async (newMacros) => { + const updatedMacros = normalizeSortOrders(newMacros); + try { + await saveMacros(updatedMacros); + notifications.success("Macro order updated successfully"); + } catch (error) { + if (error instanceof Error) { + notifications.error(`Failed to reorder macros: ${error.message}`); + showTemporaryError(error.message); + } else { + notifications.error("Failed to reorder macros"); + showTemporaryError("Failed to save reordered macros"); + } + } + }} + disabled={!!editingMacro} + variant="list" + size="XS" + handlePosition="left" + > + {(macro) => ( editingMacro && editingMacro.id === macro.id ? ( - + @@ -1211,35 +1038,8 @@ export default function SettingsMacrosRoute() {
) : ( -
handleDragStart(index)} - onDragOver={e => handleDragOver(e, index)} - onDragEnd={() => { - const allItems = document.querySelectorAll('[data-macro-item]'); - allItems.forEach(el => { - el.classList.remove('drop-target'); - el.classList.remove('dragging'); - }); - setIsDragging(false); - }} - onDrop={handleDrop} - onTouchStart={(e) => handleTouchStart(e, index)} - onTouchMove={handleTouchMove} - onTouchEnd={handleTouchEnd} - className={`macro-sortable flex items-center justify-between rounded-md border border-slate-200 p-2 dark:border-slate-700 ${ - isDragging && dragItem.current === index - ? "bg-blue-50 dark:bg-blue-900/20" - : "bg-white dark:bg-slate-800" - }`} - > -
- -
- -
+
+

{macro.name}

@@ -1292,7 +1092,7 @@ export default function SettingsMacrosRoute() {

-
+
{macroToDelete === macro.id ? (
@@ -1343,7 +1143,7 @@ export default function SettingsMacrosRoute() {
) )} -
+ )}