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">
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
import type { Radar } from "../types"
|
import type { Radar } from "../types"
|
||||||
import { MIN_CRITERIA, MAX_CRITERIA } from "../types"
|
import { MIN_CRITERIA, MAX_CRITERIA } from "../types"
|
||||||
import { useRadars } from "../storage"
|
import { useRadars } from "../storage"
|
||||||
|
import { useDragReorder } from "../utils/reorder"
|
||||||
|
|
||||||
const props = defineProps<{ radar: Radar }>()
|
const props = defineProps<{ radar: Radar }>()
|
||||||
const { update, blankCriterion } = useRadars()
|
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 {
|
function addCriterion(): void {
|
||||||
update(props.radar.id, (r) => {
|
update(props.radar.id, (r) => {
|
||||||
if (r.criteria.length >= MAX_CRITERIA) return
|
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">
|
<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>).
|
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not <em>Price</em>).
|
||||||
</p>
|
</p>
|
||||||
<ul class="flex flex-col gap-2">
|
<ul ref="listEl" class="flex flex-col gap-2">
|
||||||
<li v-for="c in radar.criteria" :key="c.id" class="flex items-center 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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm flex-1"
|
class="input input-bordered input-sm flex-1"
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
import type { Radar } from "../types"
|
import type { Radar } from "../types"
|
||||||
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types"
|
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types"
|
||||||
import { useRadars } from "../storage"
|
import { useRadars } from "../storage"
|
||||||
|
import { useDragReorder } from "../utils/reorder"
|
||||||
|
|
||||||
const props = defineProps<{ radar: Radar }>()
|
const props = defineProps<{ radar: Radar }>()
|
||||||
const { update, blankProfile } = useRadars()
|
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 {
|
function addProfile(): void {
|
||||||
update(props.radar.id, (r) => {
|
update(props.radar.id, (r) => {
|
||||||
if (r.profiles.length >= MAX_PROFILES) return
|
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>,
|
Each Profile is one shape on the radar (e.g. <em>Us today</em>, <em>Us 2027</em>,
|
||||||
<em>Acme Corp</em>).
|
<em>Acme Corp</em>).
|
||||||
</p>
|
</p>
|
||||||
<ul class="flex flex-col gap-3">
|
<ul ref="listEl" class="flex flex-col gap-3">
|
||||||
<li
|
<li
|
||||||
v-for="p in radar.profiles"
|
v-for="p in radar.profiles"
|
||||||
:key="p.id"
|
: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">
|
<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
|
<span
|
||||||
class="inline-block w-4 h-4 rounded"
|
class="inline-block w-4 h-4 rounded"
|
||||||
:style="{ background: p.color }"
|
: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