feat: reorder criteria and profiles by dragging
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:
@@ -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"
|
||||
|
||||
@@ -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
62
src/utils/reorder.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user