feat(gui-v2): DraggableBlock §7.1 abstraction (PointerEvent drag, A2-reconciled) + CT + stories
This commit is contained in:
36
apps/app/src/components-v2/shared/DraggableBlock.stories.ts
Normal file
36
apps/app/src/components-v2/shared/DraggableBlock.stories.ts
Normal 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:00–22: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' } }
|
||||||
147
apps/app/src/components-v2/shared/DraggableBlock.vue
Normal file
147
apps/app/src/components-v2/shared/DraggableBlock.vue
Normal 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>
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
39
apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts
Normal file
39
apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts
Normal 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:00–22:00',
|
||||||
|
line2Right: { progress: 60 },
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(component).toHaveScreenshot('draggableblock-selected.png')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user