feat: reorder criteria and profiles by dragging
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 1m8s
Deploy to GitHub Pages / deploy (push) Has been skipped

Add a drag handle to each criterion and profile row, backed by a
pointer-based composable that works with mouse and touch. Reordering
criteria changes the chart spoke order; reordering profiles changes
draw/legend order. Scores are keyed by id, so they follow their row.
This commit is contained in:
Julien Calixte
2026-06-26 10:36:00 +01:00
parent a51da74cbb
commit 7a22e40268
3 changed files with 116 additions and 4 deletions

View File

@@ -1,11 +1,24 @@
<script setup lang="ts">
import { ref } from "vue"
import type { Radar } from "../types"
import { MIN_CRITERIA, MAX_CRITERIA } from "../types"
import { useRadars } from "../storage"
import { useDragReorder } from "../utils/reorder"
const props = defineProps<{ radar: Radar }>()
const { update, blankCriterion } = useRadars()
const listEl = ref<HTMLElement | null>(null)
const { draggingId, startDrag } = useDragReorder({
container: listEl,
getIds: () => props.radar.criteria.map((c) => c.id),
reorder: (from, to) =>
update(props.radar.id, (r) => {
const [moved] = r.criteria.splice(from, 1)
r.criteria.splice(to, 0, moved)
}),
})
function addCriterion(): void {
update(props.radar.id, (r) => {
if (r.criteria.length >= MAX_CRITERIA) return
@@ -39,8 +52,22 @@ function renameCriterion(criterionId: string, name: string): void {
<p class="text-xs text-base-content/60 mb-3">
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not <em>Price</em>).
</p>
<ul class="flex flex-col gap-2">
<li v-for="c in radar.criteria" :key="c.id" class="flex items-center gap-2">
<ul ref="listEl" class="flex flex-col gap-2">
<li
v-for="c in radar.criteria"
:key="c.id"
data-reorder-item
class="flex items-center gap-2 rounded-lg transition-opacity"
:class="draggingId === c.id ? 'opacity-50' : ''"
>
<span
class="cursor-grab touch-none select-none px-1 text-base-content/40 hover:text-base-content/70 active:cursor-grabbing"
aria-label="Drag to reorder"
title="Drag to reorder"
@pointerdown="startDrag($event, c.id)"
>
</span>
<input
type="text"
class="input input-bordered input-sm flex-1"

View File

@@ -1,11 +1,24 @@
<script setup lang="ts">
import { ref } from "vue"
import type { Radar } from "../types"
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types"
import { useRadars } from "../storage"
import { useDragReorder } from "../utils/reorder"
const props = defineProps<{ radar: Radar }>()
const { update, blankProfile } = useRadars()
const listEl = ref<HTMLElement | null>(null)
const { draggingId, startDrag } = useDragReorder({
container: listEl,
getIds: () => props.radar.profiles.map((p) => p.id),
reorder: (from, to) =>
update(props.radar.id, (r) => {
const [moved] = r.profiles.splice(from, 1)
r.profiles.splice(to, 0, moved)
}),
})
function addProfile(): void {
update(props.radar.id, (r) => {
if (r.profiles.length >= MAX_PROFILES) return
@@ -47,13 +60,23 @@ function setColor(profileId: string, color: string): void {
Each Profile is one shape on the radar (e.g. <em>Us today</em>, <em>Us 2027</em>,
<em>Acme Corp</em>).
</p>
<ul class="flex flex-col gap-3">
<ul ref="listEl" class="flex flex-col gap-3">
<li
v-for="p in radar.profiles"
:key="p.id"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 bg-base-100"
data-reorder-item
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 bg-base-100 transition-opacity"
:class="draggingId === p.id ? 'opacity-50' : ''"
>
<div class="flex items-center gap-2">
<span
class="cursor-grab touch-none select-none text-base-content/40 hover:text-base-content/70 active:cursor-grabbing"
aria-label="Drag to reorder"
title="Drag to reorder"
@pointerdown="startDrag($event, p.id)"
>
</span>
<span
class="inline-block w-4 h-4 rounded"
:style="{ background: p.color }"

62
src/utils/reorder.ts Normal file
View File

@@ -0,0 +1,62 @@
import { onScopeDispose, ref, type Ref } from "vue"
/**
* Pointer-based drag-to-reorder for a vertical list. Works with mouse and touch
* (via Pointer Events). The list must render its items in array order, each item
* element carrying a `data-reorder-item` attribute, inside `container`.
*
* Reordering is live: as the pointer crosses a neighbour's midpoint, `reorder`
* is called to move the dragged item, and the list re-renders in the new order.
*/
export function useDragReorder(opts: {
container: Ref<HTMLElement | null>
getIds: () => string[]
reorder: (from: number, to: number) => void
}) {
const draggingId = ref<string | null>(null)
// Insertion index (in current DOM order) for the given pointer Y.
function indexFromPoint(clientY: number): number {
const el = opts.container.value
if (!el) return -1
const items = Array.from(el.querySelectorAll<HTMLElement>("[data-reorder-item]"))
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect()
if (clientY < r.top + r.height / 2) return i
}
return items.length
}
function onMove(e: PointerEvent): void {
const id = draggingId.value
if (id == null) return
const ids = opts.getIds()
const from = ids.indexOf(id)
if (from === -1) return
let to = indexFromPoint(e.clientY)
if (to === -1) return
// The dragged item still occupies its slot, so an insertion point below it
// shifts down by one once it's removed.
if (from < to) to--
if (to !== from) opts.reorder(from, to)
}
function stop(): void {
draggingId.value = null
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
}
function startDrag(e: PointerEvent, id: string): void {
e.preventDefault()
draggingId.value = id
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
}
onScopeDispose(stop)
return { draggingId, startDrag }
}