diff --git a/app/features/comp-analyzer/components/SelectedWeapons.browser.test.tsx b/app/features/comp-analyzer/components/SelectedWeapons.browser.test.tsx index 1a94d9335..4acbadaec 100644 --- a/app/features/comp-analyzer/components/SelectedWeapons.browser.test.tsx +++ b/app/features/comp-analyzer/components/SelectedWeapons.browser.test.tsx @@ -8,6 +8,7 @@ import { SelectedWeapons } from "./SelectedWeapons"; const defaultProps: ComponentProps = { selectedWeaponIds: [], onRemove: vi.fn(), + onReorder: vi.fn(), }; function renderSelectedWeapons( @@ -100,9 +101,7 @@ describe("SelectedWeapons", () => { selectedWeaponIds: [0], }); - const removeButton = screen.getByRole("button", { - name: "Remove weapon", - }); + const removeButton = screen.getByTestId("remove-weapon-0"); await expect.element(removeButton).toBeVisible(); }); diff --git a/app/features/comp-analyzer/components/SelectedWeapons.module.css b/app/features/comp-analyzer/components/SelectedWeapons.module.css index 035001244..144e3bbfd 100644 --- a/app/features/comp-analyzer/components/SelectedWeapons.module.css +++ b/app/features/comp-analyzer/components/SelectedWeapons.module.css @@ -8,6 +8,11 @@ display: flex; align-items: center; gap: var(--s-2); + transition: opacity 0.2s; + + &.isDragging { + opacity: 0.5; + } } .weaponImageContainer { @@ -100,6 +105,28 @@ } } +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: none; + border: none; + font-size: var(--font-md); + line-height: 1; + color: var(--color-text-high); + cursor: grab; + user-select: none; + touch-action: none; + flex-shrink: 0; + + &:active { + cursor: grabbing; + } +} + .subSpecialContainer { display: flex; gap: var(--s-2); diff --git a/app/features/comp-analyzer/components/SelectedWeapons.tsx b/app/features/comp-analyzer/components/SelectedWeapons.tsx index e511d53a8..35b7328d3 100644 --- a/app/features/comp-analyzer/components/SelectedWeapons.tsx +++ b/app/features/comp-analyzer/components/SelectedWeapons.tsx @@ -1,3 +1,20 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import clsx from "clsx"; import { useTranslation } from "react-i18next"; import { Image, WeaponImage } from "~/components/Image"; import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; @@ -13,81 +30,163 @@ import styles from "./SelectedWeapons.module.css"; interface SelectedWeaponsProps { selectedWeaponIds: MainWeaponId[]; onRemove: (index: number) => void; + onReorder: (newIds: MainWeaponId[]) => void; } export function SelectedWeapons({ selectedWeaponIds, onRemove, + onReorder, }: SelectedWeaponsProps) { const { t } = useTranslation(["weapons", "analyzer"]); - const slots = Array.from({ length: MAX_WEAPONS }, (_, i) => { - return selectedWeaponIds[i] ?? null; - }); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = selectedWeaponIds.indexOf(active.id as MainWeaponId); + const newIndex = selectedWeaponIds.indexOf(over.id as MainWeaponId); + + const newIds = [...selectedWeaponIds]; + const [removed] = newIds.splice(oldIndex, 1); + newIds.splice(newIndex, 0, removed); + + onReorder(newIds); + } + }; + + const emptySlotCount = MAX_WEAPONS - selectedWeaponIds.length; + const showDragHandle = selectedWeaponIds.length > 1; return (
- {slots.map((weaponId, index) => { - if (weaponId === null) { - return ( -
-
- -
-
- - {t("analyzer:comp.pickWeapon")} - -
-
-
- ); - } - - const params = mainWeaponParams(weaponId); - - return ( -
-
- -
-
- - {t(`weapons:MAIN_${weaponId}`)} - - -
-
-
- {t(`weapons:SUB_${params.subWeaponId}`)} -
-
- {t(`weapons:SPECIAL_${params.specialWeaponId}`)} -
-
+ + + {selectedWeaponIds.map((weaponId, index) => ( + + ))} + + + {Array.from({ length: emptySlotCount }, (_, i) => ( +
+
+
- ); - })} +
+ + {t("analyzer:comp.pickWeapon")} + +
+
+
+ ))} +
+ ); +} + +interface SortableWeaponRowProps { + weaponId: MainWeaponId; + index: number; + onRemove: (index: number) => void; + showDragHandle: boolean; +} + +function SortableWeaponRow({ + weaponId, + index, + onRemove, + showDragHandle, +}: SortableWeaponRowProps) { + const { t } = useTranslation(["weapons", "analyzer"]); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: weaponId }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const params = mainWeaponParams(weaponId); + + return ( +
+
+ +
+
+ + {t(`weapons:MAIN_${weaponId}`)} + + {showDragHandle ? ( + + ) : null} + +
+
+
+ {t(`weapons:SUB_${params.subWeaponId}`)} +
+
+ {t(`weapons:SPECIAL_${params.specialWeaponId}`)} +
+
); } diff --git a/app/features/comp-analyzer/routes/comp-analyzer.tsx b/app/features/comp-analyzer/routes/comp-analyzer.tsx index 19eee3712..e7b7ed91b 100644 --- a/app/features/comp-analyzer/routes/comp-analyzer.tsx +++ b/app/features/comp-analyzer/routes/comp-analyzer.tsx @@ -78,6 +78,7 @@ function CompAnalyzerPage() {