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