Draggable tiers

This commit is contained in:
Kalle
2026-09-22 21:34:25 +03:00
parent d0d8b893db
commit 6b40e59df6
9 changed files with 242 additions and 120 deletions

View File

@@ -5,6 +5,12 @@
min-height: 68px;
}
.containerReordering {
position: relative;
z-index: 1;
opacity: 0.75;
}
.tierLabel {
grid-column: 1;
display: flex;
@@ -93,14 +99,8 @@
justify-content: center;
}
.arrowControls {
.dragHandle {
grid-column: 3;
display: grid;
grid-template-rows: 1fr 1fr;
gap: var(--s-1);
}
.arrowButton {
display: flex;
align-items: center;
justify-content: center;
@@ -109,31 +109,24 @@
padding: var(--s-1);
background: var(--color-bg-high);
border: none;
cursor: pointer;
transition: opacity 0.2s;
border-radius: 0 var(--radius-field) var(--radius-field) 0;
color: var(--color-text-high);
cursor: grab;
touch-action: none;
transition: color 0.2s;
&:hover:not(:disabled) {
opacity: 0.8;
&:hover {
color: var(--color-text);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
&:active {
cursor: grabbing;
}
}
.arrowButtonUpper {
border-radius: 0 var(--radius-field) 0 0;
}
.arrowButtonLower {
border-radius: 0 0 var(--radius-field) 0;
}
.arrowIcon {
.dragHandleIcon {
width: 20px;
height: 20px;
color: var(--color-text);
}
.colorGrid {

View File

@@ -2,9 +2,11 @@ import { useDroppable } from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import clsx from "clsx";
import { ChevronDown, ChevronUp, Plus, Trash } from "lucide-react";
import { GripVertical, Plus, Trash } from "lucide-react";
import type { KeyboardEvent } from "react";
import { useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -20,6 +22,7 @@ import {
isLightColor,
tierListItemId,
tierNameFontSize,
tierSortableId,
tierTextColor,
} from "../tier-list-maker-utils";
import { DraggableItem } from "./DraggableItem";
@@ -31,14 +34,11 @@ interface TierRowProps {
export function TierRow({ tier }: TierRowProps) {
const {
state,
activeItem,
getItemsInTier,
handleRemoveTier,
handleRenameTier,
handleChangeTierColor,
handleMoveTierUp,
handleMoveTierDown,
showTierHeaders,
placementMode,
selectedTierId,
@@ -56,11 +56,15 @@ export function TierRow({ tier }: TierRowProps) {
isDragging: activeItem !== null,
});
const tierIndex = state.tiers.findIndex(
(candidate) => candidate.id === tier.id,
);
const isFirstTier = tierIndex === 0;
const isLastTier = tierIndex === state.tiers.length - 1;
const {
attributes,
listeners,
setNodeRef: setSortableNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging: isReordering,
} = useSortable({ id: tierSortableId(tier.id) });
const isClickMode = placementMode === "click";
const isSelected = isClickMode && selectedTierId === tier.id;
@@ -82,7 +86,14 @@ export function TierRow({ tier }: TierRowProps) {
: {};
return (
<div className={styles.container}>
<div
ref={setSortableNodeRef}
data-tier-id={tier.id}
className={clsx(styles.container, {
[styles.containerReordering]: isReordering,
})}
style={{ transform: CSS.Translate.toString(transform), transition }}
>
{showTierHeaders ? (
<SendouPopover
trigger={
@@ -193,26 +204,16 @@ export function TierRow({ tier }: TierRowProps) {
) : null}
</div>
<div className={styles.arrowControls}>
<button
className={clsx(styles.arrowButton, styles.arrowButtonUpper)}
onClick={() => handleMoveTierUp(tier.id)}
disabled={isFirstTier}
type="button"
aria-label="Move tier up"
>
<ChevronUp className={styles.arrowIcon} />
</button>
<button
className={clsx(styles.arrowButton, styles.arrowButtonLower)}
onClick={() => handleMoveTierDown(tier.id)}
disabled={isLastTier}
type="button"
aria-label="Move tier down"
>
<ChevronDown className={styles.arrowIcon} />
</button>
</div>
<button
ref={setActivatorNodeRef}
className={styles.dragHandle}
type="button"
aria-label="Reorder tier"
{...attributes}
{...listeners}
>
<GripVertical className={styles.dragHandleIcon} />
</button>
</div>
);
}

View File

@@ -26,7 +26,11 @@ import type {
TierListState,
} from "../tier-list-maker-schemas";
import { tierListMakerSearchParams } from "../tier-list-maker-search-params";
import { addItemToTier, getNextNthForItem } from "../tier-list-maker-utils";
import {
addItemToTier,
getNextNthForItem,
tierIdFromSortableId,
} from "../tier-list-maker-utils";
export type TierListPlacementMode = "track" | "click";
@@ -46,6 +50,7 @@ export function useTierList() {
const { tiers, setTiers, persistTiersStateToParams } =
useSearchParamTiersState();
const [activeItem, setActiveItem] = React.useState<TierListItem | null>(null);
const [isReorderingTiers, setIsReorderingTiers] = React.useState(false);
const [placementMode, setPlacementMode] = usePersistedState(
placementModePersisted,
@@ -119,6 +124,10 @@ export function useTierList() {
};
const handleDragStart = (event: DragStartEvent) => {
const isTierDrag = tierIdFromSortableId(String(event.active.id)) !== null;
setIsReorderingTiers(isTierDrag);
if (isTierDrag) return;
const item = parseItemFromId(String(event.active.id));
if (item) {
setActiveItem(item);
@@ -128,7 +137,7 @@ export function useTierList() {
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) {
if (!over || tierIdFromSortableId(String(active.id))) {
return;
}
@@ -196,6 +205,13 @@ export function useTierList() {
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveItem(null);
setIsReorderingTiers(false);
const draggedTierId = tierIdFromSortableId(String(active.id));
if (draggedTierId) {
handleReorderTiers(draggedTierId, over?.id);
return;
}
if (!over) {
persistTiersStateToParams(tiers);
@@ -275,6 +291,30 @@ export function useTierList() {
persistTiersStateToParams(tiers);
};
const handleDragCancel = () => {
setActiveItem(null);
setIsReorderingTiers(false);
};
const handleReorderTiers = (
draggedTierId: string,
overId: string | number | undefined,
) => {
const overTierId = overId ? tierIdFromSortableId(String(overId)) : null;
if (!overTierId || overTierId === draggedTierId) return;
const oldIndex = tiers.tiers.findIndex((tier) => tier.id === draggedTierId);
const newIndex = tiers.tiers.findIndex((tier) => tier.id === overTierId);
if (oldIndex === -1 || newIndex === -1) return;
const newState = {
...tiers,
tiers: arrayMove(tiers.tiers, oldIndex, newIndex),
};
setTiers(newState);
persistTiersStateToParams(newState);
};
const handleAddItemToTier = (item: TierListItem, tierId: string) => {
const newState = addItemToTier(tiers, tierId, item);
if (newState === tiers) return;
@@ -408,44 +448,6 @@ export function useTierList() {
});
};
const handleMoveTierUp = (tierId: string) => {
const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId);
if (currentIndex <= 0) return;
const newTiers = [...tiers.tiers];
[newTiers[currentIndex - 1], newTiers[currentIndex]] = [
newTiers[currentIndex],
newTiers[currentIndex - 1],
];
const newState = {
...tiers,
tiers: newTiers,
};
setTiers(newState);
persistTiersStateToParams(newState);
};
const handleMoveTierDown = (tierId: string) => {
const currentIndex = tiers.tiers.findIndex((tier) => tier.id === tierId);
if (currentIndex === -1 || currentIndex >= tiers.tiers.length - 1) {
return;
}
const newTiers = [...tiers.tiers];
[newTiers[currentIndex], newTiers[currentIndex + 1]] = [
newTiers[currentIndex + 1],
newTiers[currentIndex],
];
const newState = {
...tiers,
tiers: newTiers,
};
setTiers(newState);
persistTiersStateToParams(newState);
};
const handleReset = () => {
const newState = {
tiers: DEFAULT_TIERS,
@@ -460,16 +462,16 @@ export function useTierList() {
setItemType,
state: tiers,
activeItem,
isReorderingTiers,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDragCancel,
handleAddTier,
handleAddItemToTier,
handleRemoveTier,
handleRenameTier,
handleChangeTierColor,
handleMoveTierUp,
handleMoveTierDown,
handleReset,
getItemsInTier,
availableItems: getAvailableItems(),

View File

@@ -1,4 +1,6 @@
import type { CollisionDetection, Modifier } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
@@ -8,7 +10,12 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import clsx from "clsx";
import { HardDriveDownload, Plus, RefreshCcw } from "lucide-react";
import { useState } from "react";
@@ -47,11 +54,33 @@ import {
} from "../contexts/TierListContext";
import type { TierListPlacementMode } from "../hooks/useTierList";
import type { TierListItem } from "../tier-list-maker-schemas";
import { tierListMakerPathWithState } from "../tier-list-maker-utils";
import {
tierIdFromSortableId,
tierListMakerPathWithState,
tierSortableId,
} from "../tier-list-maker-utils";
import styles from "./tier-list-maker.module.css";
const PLACEMENT_MODES: TierListPlacementMode[] = ["track", "click"];
/** Tier rows and items share one context, so each drag only collides with its own kind of target. */
const tierAwareCollisionDetection: CollisionDetection = (args) => {
const isTierDrag = tierIdFromSortableId(String(args.active.id)) !== null;
const droppableContainers = args.droppableContainers.filter(
(container) =>
(tierIdFromSortableId(String(container.id)) !== null) === isTierDrag,
);
return isTierDrag
? closestCenter({ ...args, droppableContainers })
: pointerWithin({ ...args, droppableContainers });
};
const restrictTierDragToVerticalAxis: Modifier = (args) =>
args.active && tierIdFromSortableId(String(args.active.id)) !== null
? restrictToVerticalAxis(args)
: args.transform;
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Tier List Maker",
@@ -97,9 +126,11 @@ function TierListMakerContent() {
setItemType,
state,
activeItem,
isReorderingTiers,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDragCancel,
handleAddTier,
handleReset,
hideAltKits,
@@ -144,16 +175,23 @@ function TierListMakerContent() {
<DndContext
key={itemType}
sensors={sensors}
collisionDetection={pointerWithin}
collisionDetection={tierAwareCollisionDetection}
modifiers={[restrictTierDragToVerticalAxis]}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="stack">
<div className={styles.tierList}>
{state.tiers.map((tier) => (
<TierRow key={tier.id} tier={tier} />
))}
<SortableContext
items={state.tiers.map((tier) => tierSortableId(tier.id))}
strategy={verticalListSortingStrategy}
>
{state.tiers.map((tier) => (
<TierRow key={tier.id} tier={tier} />
))}
</SortableContext>
</div>
<div className="stack horizontal md flex-wrap items-center">
@@ -280,9 +318,12 @@ function TierListMakerContent() {
</SendouTabPanel>
</SendouTabs>
<DragOverlay>
{activeItem ? <ItemDragPreview item={activeItem} /> : null}
</DragOverlay>
{/* an empty overlay would hide the dropped tier row during its drop animation */}
{isReorderingTiers ? null : (
<DragOverlay>
{activeItem ? <ItemDragPreview item={activeItem} /> : null}
</DragOverlay>
)}
</DndContext>
</Main>
);

View File

@@ -10,6 +10,20 @@ export function tierListItemId(item: TierListItem) {
return `${item.type}:${item.id}${item.nth ? `:${item.nth}` : ""}`;
}
const TIER_SORTABLE_ID_PREFIX = "tier-sortable:";
/** Id of the tier row as a sortable, kept apart from the tier's own item drop zone id. */
export function tierSortableId(tierId: string) {
return `${TIER_SORTABLE_ID_PREFIX}${tierId}`;
}
/** Tier id behind a sortable id, or `null` if the id belongs to something else being dragged. */
export function tierIdFromSortableId(id: string) {
return id.startsWith(TIER_SORTABLE_ID_PREFIX)
? id.slice(TIER_SORTABLE_ID_PREFIX.length)
: null;
}
/** Path that reopens the given tier list, used by the exported image's QR code. */
export function tierListMakerPathWithState({
state,

View File

@@ -0,0 +1,5 @@
---
navItem: tier-list-maker
type: feature
---
Tiers can be reordered by dragging the handle on the right side of the row

View File

@@ -504,6 +504,29 @@ export async function waitForDropToSettle(page: Page) {
await page.waitForTimeout(2 * DND_KIT_CLICK_SUPPRESSION_MS);
}
/** Drags `from` onto the center of `to` with a dnd-kit compatible stepped pointer move, then waits for the drop to settle. */
export async function dragAndDrop(
page: Page,
{ from, to }: { from: Locator; to: Locator },
) {
await from.hover();
await page.mouse.down();
const targetBox = await to.boundingBox();
if (!targetBox) {
throw new Error("The drop target has no bounding box");
}
// the drag & drop library only registers the drop when moved in steps
await page.mouse.move(
targetBox.x + targetBox.width / 2,
targetBox.y + targetBox.height / 2,
{ steps: 10 },
);
await page.mouse.up();
await waitForDropToSettle(page);
}
/**
* The scroll position the page was at when it was last pressed, for asserting that
* an action did not move the viewer. Playwright scrolls a click target into view

View File

@@ -1,11 +1,10 @@
import type { Page } from "@playwright/test";
import { invariant } from "~/utils/invariant";
import { TIER_LIST_MAKER_URL } from "~/utils/urls";
import {
dragAndDrop,
expect,
expectIsHydrated,
navigate,
waitForDropToSettle,
} from "../../helpers/playwright";
type ItemType =
@@ -49,6 +48,7 @@ export class TierListMakerPage {
this.locators = {
emptyTiersDragMode: page.getByText("Drop items here"),
emptyTiersClickMode: page.getByText("Click items to add here"),
tierDragHandles: page.getByRole("button", { name: "Reorder tier" }),
};
}
@@ -88,20 +88,30 @@ export class TierListMakerPage {
const emptyTiers = this.locators.emptyTiersDragMode;
const emptyCountBefore = await emptyTiers.count();
await this.poolItems(type).first().hover();
await this.page.mouse.down();
const tierBox = await emptyTiers.last().boundingBox();
invariant(tierBox, "The tier dropped on has no bounding box");
await this.page.mouse.move(
tierBox.x + tierBox.width / 2,
tierBox.y + tierBox.height / 2,
{ steps: 10 },
);
await this.page.mouse.up();
await dragAndDrop(this.page, {
from: this.poolItems(type).first(),
to: emptyTiers.last(),
});
await expect(emptyTiers).toHaveCount(emptyCountBefore - 1);
await waitForDropToSettle(this.page);
}
/** Ids of the tier rows, top to bottom. */
async tierIds() {
return this.page
.locator("[data-tier-id]")
.evaluateAll((rows) =>
rows.map((row) => (row as HTMLElement).dataset.tierId),
);
}
async dragTier({ from, to }: { from: number; to: number }) {
const handles = this.locators.tierDragHandles;
await dragAndDrop(this.page, {
from: handles.nth(from),
to: handles.nth(to),
});
}
async clickFirstItem(type: ItemType) {

View File

@@ -39,7 +39,7 @@ test.describe("Tier List Maker", () => {
const tierList = new TierListMakerPage(page);
await tierList.goto();
// click to place is the default mode
await tierList.setPlacementMode("click");
await expect(tierList.locators.emptyTiersClickMode).toHaveCount(5);
// the first tier is selected by default
@@ -50,4 +50,37 @@ test.describe("Tier List Maker", () => {
await tierList.clickFirstItem("main-weapon");
await expect(tierList.locators.emptyTiersClickMode).toHaveCount(3);
});
test("tiers are reordered by dragging their handle", async ({ page }) => {
const tierList = new TierListMakerPage(page);
await tierList.goto();
expect(await tierList.tierIds()).toEqual([
"tier-x",
"tier-s",
"tier-a",
"tier-b",
"tier-c",
]);
await tierList.dragTier({ from: 0, to: 2 });
expect(await tierList.tierIds()).toEqual([
"tier-s",
"tier-a",
"tier-x",
"tier-b",
"tier-c",
]);
await tierList.reload();
expect(await tierList.tierIds()).toEqual([
"tier-s",
"tier-a",
"tier-x",
"tier-b",
"tier-c",
]);
});
});