From 814d11c8dbf184976855472a0702efcaf73f5fdc Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Mon, 18 May 2026 11:48:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(gui-v2):=20DraggableBlock=20=C2=A77.1=20ab?= =?UTF-8?q?straction=20(PointerEvent=20drag,=20A2-reconciled)=20+=20CT=20+?= =?UTF-8?q?=20stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/DraggableBlock.stories.ts | 36 +++++ .../components-v2/shared/DraggableBlock.vue | 147 ++++++++++++++++++ .../shared/__tests__/DraggableBlock.spec.ts | 63 ++++++++ .../v2/draggableblock.ct.spec.ts | 39 +++++ 4 files changed, 285 insertions(+) create mode 100644 apps/app/src/components-v2/shared/DraggableBlock.stories.ts create mode 100644 apps/app/src/components-v2/shared/DraggableBlock.vue create mode 100644 apps/app/src/components-v2/shared/__tests__/DraggableBlock.spec.ts create mode 100644 apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts diff --git a/apps/app/src/components-v2/shared/DraggableBlock.stories.ts b/apps/app/src/components-v2/shared/DraggableBlock.stories.ts new file mode 100644 index 00000000..2447ef83 --- /dev/null +++ b/apps/app/src/components-v2/shared/DraggableBlock.stories.ts @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import DraggableBlock from '@/components-v2/shared/DraggableBlock.vue' + +const meta: Meta = { + title: 'Shared/DraggableBlock', + component: DraggableBlock, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +/** 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' } } diff --git a/apps/app/src/components-v2/shared/DraggableBlock.vue b/apps/app/src/components-v2/shared/DraggableBlock.vue new file mode 100644 index 00000000..4f1762a9 --- /dev/null +++ b/apps/app/src/components-v2/shared/DraggableBlock.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/apps/app/src/components-v2/shared/__tests__/DraggableBlock.spec.ts b/apps/app/src/components-v2/shared/__tests__/DraggableBlock.spec.ts new file mode 100644 index 00000000..ac28b009 --- /dev/null +++ b/apps/app/src/components-v2/shared/__tests__/DraggableBlock.spec.ts @@ -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: '{{ value }}' }), + ProgressBar: defineComponent({ name: 'ProgressBar', props: ['value'], template: '
' }), +} + +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() + }) +}) diff --git a/apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts b/apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts new file mode 100644 index 00000000..68d84cf1 --- /dev/null +++ b/apps/app/tests/playwright-ct/v2/draggableblock.ct.spec.ts @@ -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') +})