feat(gui-v2): DraggableBlock §7.1 abstraction (PointerEvent drag, A2-reconciled) + CT + stories

This commit is contained in:
2026-05-18 11:48:59 +02:00
parent 91d20d0dd2
commit 814d11c8db
4 changed files with 285 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue'
const meta: Meta<typeof DraggableBlock> = {
title: 'Shared/DraggableBlock',
component: DraggableBlock,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof DraggableBlock>
/** A2 retrofit-prove: TimetableGrid performance block via the §7.1 contract. */
export const ArtistBlock: Story = {
args: {
line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } },
line1Right: { pill: 'Main Stage' },
line2Left: '21:0022:00',
line2Right: { progress: 60 },
},
}
/** A2 retrofit-prove: CueTimelineEditor cue block via the §7.1 contract. */
export const CueBlock: Story = {
args: {
line1Left: { text: 'Intro VT', tag: { label: 'video', severity: 'info' } },
line1Right: { pill: 'PGM' },
line2Left: '00:30',
line2Right: null,
},
}
export const WithProgress: Story = { args: { line1Left: { text: 'Set' }, line2Right: { progress: 80 } } }
export const Selected: Story = { args: { line1Left: { text: 'Selected' }, selected: true } }
export const Dragging: Story = { args: { line1Left: { text: 'Dragging' }, dragging: true } }
export const Compact: Story = { args: { line1Left: { text: 'Compact' }, density: 'compact' } }
export const Comfy: Story = { args: { line1Left: { text: 'Comfy' }, density: 'comfy' } }

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
/**
* DraggableBlock — spec §7.1 canonical contract. ABSTRACTION over two
* crewli-starter consumers (TimetableGrid mousedown+px/min;
* CueTimelineEditor HTML5 drag) reconciled in Task A2. This component
* is presentational + emits normalised pointer drag; it performs NO
* snap/lane/px-min math — the parent owns all positioning.
*
* Scoped CSS is the spec §7.1-justified bespoke case (crewli-starter
* 2-line block spacing has no Tailwind/PrimeVue expression).
*/
import { ref } from 'vue'
import Tag from 'primevue/tag'
import ProgressBar from 'primevue/progressbar'
import type { TagSeverity } from '@/components-v2/shared/statusSeverity'
interface TagBit { label: string; severity?: TagSeverity }
const props = withDefaults(defineProps<{
line1Left: { tag?: TagBit; text?: string }
line1Right?: { tag?: TagBit; pill?: string }
line2Left?: string
line2Right?: { progress: number } | null
selected?: boolean
dragging?: boolean
density?: 'compact' | 'regular' | 'comfy'
}>(), { density: 'regular', selected: false, dragging: false, line2Right: null })
const emit = defineEmits<{
click: []
dragstart: [e: PointerEvent]
dragend: [delta: { x: number; y: number }]
}>()
const THRESHOLD = 3
const startX = ref(0)
const startY = ref(0)
const moved = ref(false)
const active = ref(false)
function onPointerDown(e: PointerEvent): void {
if (e.button !== 0)
return
active.value = true
moved.value = false
startX.value = e.clientX
startY.value = e.clientY
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
}
function onPointerMove(e: PointerEvent): void {
if (!active.value)
return
if (!moved.value && (Math.abs(e.clientX - startX.value) > THRESHOLD || Math.abs(e.clientY - startY.value) > THRESHOLD)) {
moved.value = true
emit('dragstart', e)
}
}
function onPointerUp(e: PointerEvent): void {
if (!active.value)
return
active.value = false
if (moved.value)
emit('dragend', { x: e.clientX - startX.value, y: e.clientY - startY.value })
else
emit('click')
}
</script>
<template>
<div
class="db"
:data-density="density"
:class="{ 'is-selected': selected, 'is-dragging': dragging }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@lostpointercapture="onPointerUp"
>
<div class="db-row">
<span class="db-left">
<Tag
v-if="line1Left.tag"
:value="line1Left.tag.label"
:severity="line1Left.tag.severity"
/>
<span
v-if="line1Left.text"
class="db-text"
>{{ line1Left.text }}</span>
</span>
<span
v-if="line1Right"
class="db-right"
>
<Tag
v-if="line1Right.tag"
:value="line1Right.tag.label"
:severity="line1Right.tag.severity"
/>
<span
v-if="line1Right.pill"
class="db-pill"
>{{ line1Right.pill }}</span>
</span>
</div>
<div
v-if="line2Left || line2Right"
class="db-row db-row2"
>
<span
v-if="line2Left"
class="db-time"
>{{ line2Left }}</span>
<ProgressBar
v-if="line2Right"
:value="line2Right.progress"
:show-value="false"
class="db-progress"
/>
</div>
</div>
</template>
<style scoped>
/* spec §7.1 justified bespoke — crewli-starter block spacing. */
.db {
display: flex; flex-direction: column; justify-content: center; gap: 2px;
padding: 4px 8px; border-radius: var(--p-border-radius);
border: 1px solid var(--p-content-border-color);
background: var(--p-content-background);
user-select: none; cursor: grab; overflow: hidden;
}
.db[data-density="compact"] { min-height: 56px; }
.db[data-density="regular"] { min-height: 64px; }
.db[data-density="comfy"] { min-height: 76px; }
.db.is-selected { border-color: var(--p-primary-color); box-shadow: 0 0 0 2px color-mix(in srgb, var(--p-primary-color) 30%, transparent); }
.db.is-dragging { cursor: grabbing; opacity: .85; }
.db-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
.db-left, .db-right { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
.db-text { font-size: 13px; font-weight: 600; color: var(--p-text-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.db-pill { font-size: 11px; color: var(--p-text-muted-color); }
.db-row2 { gap: 6px; }
.db-time { font-size: 11px; color: var(--p-text-muted-color); }
.db-progress { flex: 1; height: 4px; }
</style>

View File

@@ -0,0 +1,63 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue'
const stubs = {
Tag: defineComponent({ name: 'Tag', props: ['value', 'severity'], template: '<span class="tag-stub" :data-sev="severity">{{ value }}</span>' }),
ProgressBar: defineComponent({ name: 'ProgressBar', props: ['value'], template: '<div class="pb-stub" :data-val="value" />' }),
}
interface DBProps {
line1Left: { tag?: { label: string; severity?: 'success' | 'warn' | 'info' | 'secondary' | 'danger' }; text?: string }
line1Right?: { tag?: { label: string; severity?: 'success' | 'warn' | 'info' | 'secondary' | 'danger' }; pill?: string }
line2Left?: string
line2Right?: { progress: number } | null
selected?: boolean
dragging?: boolean
density?: 'compact' | 'regular' | 'comfy'
}
const mountDB = (props: DBProps) =>
mount(DraggableBlock, { props, global: { stubs }, attachTo: document.body })
describe('DraggableBlock', () => {
it('renders line1Left text + tag and line2Right progress', () => {
const w = mountDB({
line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } },
line2Right: { progress: 42 },
})
expect(w.text()).toContain('DJ Foo')
expect(w.get('.tag-stub').attributes('data-sev')).toBe('success')
expect(w.get('.pb-stub').attributes('data-val')).toBe('42')
})
it('density prop maps to the row-height data attribute', () => {
expect(mountDB({ line1Left: { text: 'x' }, density: 'compact' }).get('[data-density]').attributes('data-density')).toBe('compact')
expect(mountDB({ line1Left: { text: 'x' }, density: 'comfy' }).get('[data-density]').attributes('data-density')).toBe('comfy')
})
it('pointer without movement → click emitted, no dragstart/dragend', async () => {
const w = mountDB({ line1Left: { text: 'x' } })
const el = w.get('[data-density]')
await el.trigger('pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 1 })
await el.trigger('pointerup', { clientX: 10, clientY: 10, pointerId: 1 })
expect(w.emitted('click')).toHaveLength(1)
expect(w.emitted('dragstart')).toBeUndefined()
expect(w.emitted('dragend')).toBeUndefined()
})
it('pointer past 3px threshold → dragstart once, dragend with delta, no click', async () => {
const w = mountDB({ line1Left: { text: 'x' } })
const el = w.get('[data-density]')
await el.trigger('pointerdown', { button: 0, clientX: 0, clientY: 0, pointerId: 1 })
await el.trigger('pointermove', { clientX: 20, clientY: 8, pointerId: 1 })
await el.trigger('pointerup', { clientX: 20, clientY: 8, pointerId: 1 })
expect(w.emitted('dragstart')).toHaveLength(1)
expect(w.emitted('dragend')![0]).toEqual([{ x: 20, y: 8 }])
expect(w.emitted('click')).toBeUndefined()
})
})

View File

@@ -0,0 +1,39 @@
import { expect, test } from '@playwright/experimental-ct-vue'
import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue'
test('drag past threshold emits dragstart then dragend with delta @ct', async ({ mount }) => {
const events: string[] = []
const component = await mount(DraggableBlock, {
props: { line1Left: { text: 'DJ Foo' } },
on: {
dragstart: () => events.push('dragstart'),
dragend: (d: { x: number; y: number }) => events.push(`dragend:${d.x},${d.y}`),
click: () => events.push('click'),
},
})
const box = (await component.boundingBox())!
await component.page().mouse.move(box.x + 10, box.y + 10)
await component.page().mouse.down()
await component.page().mouse.move(box.x + 40, box.y + 22)
await component.page().mouse.up()
expect(events[0]).toBe('dragstart')
expect(events.some(e => e.startsWith('dragend:'))).toBe(true)
expect(events).not.toContain('click')
})
test('static states render for @visual baseline @visual', async ({ mount }) => {
const component = await mount(DraggableBlock, {
props: {
line1Left: { text: 'DJ Foo', tag: { label: 'confirmed', severity: 'success' } },
line1Right: { pill: 'Main' },
line2Left: '21:0022:00',
line2Right: { progress: 60 },
selected: true,
},
})
await expect(component).toHaveScreenshot('draggableblock-selected.png')
})