From 6b40e59df6e253fa9d65a078e2b709a0a6c3acc0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:34:25 +0300 Subject: [PATCH] Draggable tiers --- .../components/TierRow.module.css | 41 ++++----- .../tier-list-maker/components/TierRow.tsx | 61 ++++++------- .../tier-list-maker/hooks/useTierList.ts | 86 ++++++++++--------- .../routes/tier-list-maker.tsx | 59 +++++++++++-- .../tier-list-maker/tier-list-maker-utils.ts | 14 +++ ...2026-09-22-tier-list-maker-tier-reorder.md | 5 ++ e2e/helpers/playwright.ts | 23 +++++ .../tier-list-maker/tier-list-maker-page.ts | 38 +++++--- e2e/tier-list-maker.spec.ts | 35 +++++++- 9 files changed, 242 insertions(+), 120 deletions(-) create mode 100644 changelog/2026-09-22-tier-list-maker-tier-reorder.md diff --git a/app/features/tier-list-maker/components/TierRow.module.css b/app/features/tier-list-maker/components/TierRow.module.css index 2f119d797..16690127e 100644 --- a/app/features/tier-list-maker/components/TierRow.module.css +++ b/app/features/tier-list-maker/components/TierRow.module.css @@ -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 { diff --git a/app/features/tier-list-maker/components/TierRow.tsx b/app/features/tier-list-maker/components/TierRow.tsx index 34a19f768..912a310a7 100644 --- a/app/features/tier-list-maker/components/TierRow.tsx +++ b/app/features/tier-list-maker/components/TierRow.tsx @@ -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 ( -
+
{showTierHeaders ? ( -
- - -
+
); } diff --git a/app/features/tier-list-maker/hooks/useTierList.ts b/app/features/tier-list-maker/hooks/useTierList.ts index 655a0cb43..783685aac 100644 --- a/app/features/tier-list-maker/hooks/useTierList.ts +++ b/app/features/tier-list-maker/hooks/useTierList.ts @@ -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(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(), diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx index a3fde3891..21a2df76a 100644 --- a/app/features/tier-list-maker/routes/tier-list-maker.tsx +++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx @@ -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() {
- {state.tiers.map((tier) => ( - - ))} + tierSortableId(tier.id))} + strategy={verticalListSortingStrategy} + > + {state.tiers.map((tier) => ( + + ))} +
@@ -280,9 +318,12 @@ function TierListMakerContent() { - - {activeItem ? : null} - + {/* an empty overlay would hide the dropped tier row during its drop animation */} + {isReorderingTiers ? null : ( + + {activeItem ? : null} + + )} ); diff --git a/app/features/tier-list-maker/tier-list-maker-utils.ts b/app/features/tier-list-maker/tier-list-maker-utils.ts index 9d2d1f391..ff3ae83bc 100644 --- a/app/features/tier-list-maker/tier-list-maker-utils.ts +++ b/app/features/tier-list-maker/tier-list-maker-utils.ts @@ -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, diff --git a/changelog/2026-09-22-tier-list-maker-tier-reorder.md b/changelog/2026-09-22-tier-list-maker-tier-reorder.md new file mode 100644 index 000000000..976af2f3b --- /dev/null +++ b/changelog/2026-09-22-tier-list-maker-tier-reorder.md @@ -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 diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index 6ae95a8b6..369c883fb 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -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 diff --git a/e2e/pages/tier-list-maker/tier-list-maker-page.ts b/e2e/pages/tier-list-maker/tier-list-maker-page.ts index a1687b5ec..33376f243 100644 --- a/e2e/pages/tier-list-maker/tier-list-maker-page.ts +++ b/e2e/pages/tier-list-maker/tier-list-maker-page.ts @@ -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) { diff --git a/e2e/tier-list-maker.spec.ts b/e2e/tier-list-maker.spec.ts index 51ffd82b5..0ad348b50 100644 --- a/e2e/tier-list-maker.spec.ts +++ b/e2e/tier-list-maker.spec.ts @@ -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", + ]); + }); });