Drag to reorder weapons in comp analyzer
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-04-21 08:59:44 +03:00
parent 285e5de395
commit ab5cb9d91e
20 changed files with 208 additions and 66 deletions

View File

@@ -8,6 +8,7 @@ import { SelectedWeapons } from "./SelectedWeapons";
const defaultProps: ComponentProps<typeof SelectedWeapons> = {
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();
});

View File

@@ -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);

View File

@@ -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 (
<div className={styles.selectedWeapons} data-testid="selected-weapons">
{slots.map((weaponId, index) => {
if (weaponId === null) {
return (
<div key={`empty-${index}`} className={styles.selectedWeaponRow}>
<div className={styles.weaponImageContainerEmpty}>
<Image path={abilityImageUrl("UNKNOWN")} alt="" size={48} />
</div>
<div className={styles.weaponNamePillEmpty}>
<span className={styles.weaponNameEmpty}>
{t("analyzer:comp.pickWeapon")}
</span>
</div>
<div className={styles.subSpecialContainerSpacer} />
</div>
);
}
const params = mainWeaponParams(weaponId);
return (
<div
key={index}
className={styles.selectedWeaponRow}
data-testid={`selected-weapon-${index}`}
>
<div className={styles.weaponImageContainer}>
<WeaponImage weaponSplId={weaponId} variant="build" size={48} />
</div>
<div className={styles.weaponNamePill}>
<span className={styles.weaponName}>
{t(`weapons:MAIN_${weaponId}`)}
</span>
<button
type="button"
className={styles.removeButton}
onClick={() => onRemove(index)}
aria-label={t("analyzer:comp.removeWeapon")}
data-testid={`remove-weapon-${index}`}
>
&times;
</button>
</div>
<div className={styles.subSpecialContainer}>
<div className={styles.kitIcon}>
<Image
path={subWeaponImageUrl(params.subWeaponId)}
alt={t(`weapons:SUB_${params.subWeaponId}`)}
size={24}
/>
</div>
<div className={styles.kitIcon}>
<Image
path={specialWeaponImageUrl(params.specialWeaponId)}
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
size={24}
/>
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedWeaponIds}
strategy={verticalListSortingStrategy}
>
{selectedWeaponIds.map((weaponId, index) => (
<SortableWeaponRow
key={weaponId}
weaponId={weaponId}
index={index}
onRemove={onRemove}
showDragHandle={showDragHandle}
/>
))}
</SortableContext>
</DndContext>
{Array.from({ length: emptySlotCount }, (_, i) => (
<div key={`empty-${i}`} className={styles.selectedWeaponRow}>
<div className={styles.weaponImageContainerEmpty}>
<Image path={abilityImageUrl("UNKNOWN")} alt="" size={48} />
</div>
);
})}
<div className={styles.weaponNamePillEmpty}>
<span className={styles.weaponNameEmpty}>
{t("analyzer:comp.pickWeapon")}
</span>
</div>
<div className={styles.subSpecialContainerSpacer} />
</div>
))}
</div>
);
}
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 (
<div
ref={setNodeRef}
style={style}
className={clsx(styles.selectedWeaponRow, {
[styles.isDragging]: isDragging,
})}
data-testid={`selected-weapon-${index}`}
{...attributes}
>
<div className={styles.weaponImageContainer}>
<WeaponImage weaponSplId={weaponId} variant="build" size={48} />
</div>
<div className={styles.weaponNamePill}>
<span className={styles.weaponName}>
{t(`weapons:MAIN_${weaponId}`)}
</span>
{showDragHandle ? (
<button
type="button"
className={styles.dragHandle}
aria-label={t("analyzer:comp.reorderWeapon")}
{...listeners}
>
</button>
) : null}
<button
type="button"
className={styles.removeButton}
onClick={() => onRemove(index)}
aria-label={t("analyzer:comp.removeWeapon")}
data-testid={`remove-weapon-${index}`}
>
&times;
</button>
</div>
<div className={styles.subSpecialContainer}>
<div className={styles.kitIcon}>
<Image
path={subWeaponImageUrl(params.subWeaponId)}
alt={t(`weapons:SUB_${params.subWeaponId}`)}
size={24}
/>
</div>
<div className={styles.kitIcon}>
<Image
path={specialWeaponImageUrl(params.specialWeaponId)}
alt={t(`weapons:SPECIAL_${params.specialWeaponId}`)}
size={24}
/>
</div>
</div>
</div>
);
}

View File

@@ -78,6 +78,7 @@ function CompAnalyzerPage() {
<SelectedWeapons
selectedWeaponIds={selectedWeaponIds}
onRemove={handleRemoveWeapon}
onReorder={setSelectedWeaponIds}
/>
<WeaponCategories selectedWeaponIds={selectedWeaponIds} />
<WeaponGrid

View File

@@ -181,6 +181,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -181,6 +181,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -181,6 +181,7 @@
"comp.groupBy.special": "Special weapon",
"comp.selectWeapons": "Select up to {{max}} weapons",
"comp.removeWeapon": "Remove weapon",
"comp.reorderWeapon": "Drag to reorder weapon",
"comp.pickWeapon": "Pick a weapon",
"comp.showWeaponGrid": "Show weapon selector",
"comp.hideWeaponGrid": "Hide weapon selector",

View File

@@ -183,6 +183,7 @@
"comp.groupBy.special": "Arma especial",
"comp.selectWeapons": "Selecciona hasta {{max}} armas",
"comp.removeWeapon": "Eliminar arma",
"comp.reorderWeapon": "",
"comp.pickWeapon": "Elige un arma",
"comp.showWeaponGrid": "Mostrar selector de armas",
"comp.hideWeaponGrid": "Ocultar selector de armas",

View File

@@ -183,6 +183,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -183,6 +183,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -183,6 +183,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -183,6 +183,7 @@
"comp.groupBy.special": "נשק מיוחד",
"comp.selectWeapons": "בחר עד ל-{{max}} נשקים",
"comp.removeWeapon": "הסר נשק",
"comp.reorderWeapon": "",
"comp.pickWeapon": "בחר נשק",
"comp.showWeaponGrid": "הצג בורר נשק",
"comp.hideWeaponGrid": "הסתר בורר נשק",

View File

@@ -183,6 +183,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -177,6 +177,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -177,6 +177,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -181,6 +181,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -185,6 +185,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -183,6 +183,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -185,6 +185,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",

View File

@@ -177,6 +177,7 @@
"comp.groupBy.special": "",
"comp.selectWeapons": "",
"comp.removeWeapon": "",
"comp.reorderWeapon": "",
"comp.pickWeapon": "",
"comp.showWeaponGrid": "",
"comp.hideWeaponGrid": "",