mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-06 19:27:58 -05:00
Drag to reorder weapons in comp analyzer
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
×
|
||||
</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}`}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ function CompAnalyzerPage() {
|
||||
<SelectedWeapons
|
||||
selectedWeaponIds={selectedWeaponIds}
|
||||
onRemove={handleRemoveWeapon}
|
||||
onReorder={setSelectedWeaponIds}
|
||||
/>
|
||||
<WeaponCategories selectedWeaponIds={selectedWeaponIds} />
|
||||
<WeaponGrid
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"comp.groupBy.special": "נשק מיוחד",
|
||||
"comp.selectWeapons": "בחר עד ל-{{max}} נשקים",
|
||||
"comp.removeWeapon": "הסר נשק",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "בחר נשק",
|
||||
"comp.showWeaponGrid": "הצג בורר נשק",
|
||||
"comp.hideWeaponGrid": "הסתר בורר נשק",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"comp.groupBy.special": "",
|
||||
"comp.selectWeapons": "",
|
||||
"comp.removeWeapon": "",
|
||||
"comp.reorderWeapon": "",
|
||||
"comp.pickWeapon": "",
|
||||
"comp.showWeaponGrid": "",
|
||||
"comp.hideWeaponGrid": "",
|
||||
|
||||
Reference in New Issue
Block a user