Files
mats 7362e4730f Cheaper, clearer dropzones: memoize, hatch refused, label slots as words
DropZones re-ran the accept predicates and a DOM walk on every overlay
render (pointermove-driven). Matching now runs once per drag via
matchZones(); scroll/resize ticks only re-measure the cached rects (new
test pins the predicate count across ticks).

Refused slots get a diagonal hatch on top of the red tint so the state is
not color-only, slot tags show handles as words (`3d-accessories` ->
"3d accessories", raw key kept in title), and the ghost pop honours
prefers-reduced-motion.

The mock section template now marks its sidebar slot like a real
template, so slot tests use the fixture instead of hand-injected DOM
(the old palette test asserting "no tagged slots" now expects both
sections' slots).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-11-09 09:35:16 +01:00

516 lines
23 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { Icon, TypeIcon, registerTypeIcon } from '../src/ui/Icon'
import { slotLabel } from '../src/ui/Overlay'
import { createRoot } from 'react-dom/client'
let h: Harness
afterEach(() => h?.cleanup())
const select = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const qa = (sel: string) => [...h.shadow.querySelectorAll<HTMLElement>(sel)]
const kids = (id: string) => h.cms.graph.children(id).map((e) => e.to)
/** jsdom has no layout: give elements rects. */
const place = (id: string, x: number, y: number, w: number, hh: number) => {
byId(id).getBoundingClientRect = () => ({ x, y, left: x, top: y, right: x + w, bottom: y + hh, width: w, height: hh, toJSON() {} }) as DOMRect
}
const ptr = (type: string, x: number, y: number, el: EventTarget = document, altKey = false) => act(async () => void el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, altKey })))
describe('icons', () => {
it('Icon renders an inline svg path; unknown names fall back; types get a guessed or registered icon', async () => {
const host = document.createElement('div')
const root = createRoot(host)
await act(async () => root.render(<><Icon name="edit" /><Icon name="nope" /><TypeIcon type="consulting-heading" /><TypeIcon type="weird" /><TypeIcon type="custom-x" /></>))
expect(host.querySelectorAll('svg path')).toHaveLength(5)
const d = [...host.querySelectorAll('path')].map((p) => p.getAttribute('d'))
expect(d[0]).not.toBe(d[1]) // edit vs fallback
expect(d[1]).toBe(d[3]) // unknown name and unknown type share the generic shape
expect(d[2]).not.toBe(d[3]) // "heading" guessed from the name
registerTypeIcon('custom-x', 'M0 0h24v24H0z')
await act(async () => root.render(<TypeIcon type="custom-x" />))
expect(host.querySelector('path')!.getAttribute('d')).toBe('M0 0h24v24H0z')
})
})
describe('toolbar overflow', () => {
it('JSON lives under the … menu, not in the toolbar', async () => {
h = await setup()
await h.settle()
await select('t1')
expect(h.shadow.querySelector('[role=toolbar] [aria-label="JSON"]')).toBeNull()
expect(h.shadow.querySelector('.cms-menu')).toBeNull()
await h.press('More actions')
expect(qa('.cms-menu [role=menuitem]').map((b) => b.textContent!.trim())).toEqual(['JSON', 'Copy node id'])
await act(async () => void qa('.cms-menu [role=menuitem]')[0].click())
await h.settle()
expect(h.shadow.querySelector('.cms-menu')).toBeNull() // closes on choice
expect(q('.cms-dialog').textContent).toContain('JSON')
})
})
describe('every property editable where it is rendered', () => {
it('binds nested properties to rendered text on selection and edits them in place with one click', async () => {
h = await setup()
await h.settle()
await select('f1')
const marked = [...byId('f1').querySelectorAll('[data-cms-inferred]')].map((e) => [e.getAttribute('data-cms-field'), e.textContent])
expect(marked).toEqual([['companyName', 'Mock AB'], ['links.0.text', 'About'], ['links.1.text', 'Careers']])
const about = byId('f1').querySelectorAll('a')[0] as HTMLElement
await act(async () => void click(about)) // block already selected: edits instead of selecting
await h.settle()
expect(about.getAttribute('contenteditable')).toBeTruthy()
about.textContent = 'About us'
about.dispatchEvent(new Event('input', { bubbles: true }))
h.editor.ctrl.stopInline()
await h.settle()
const links = h.cms.graph.node('f1').data.links
expect(links).toEqual([{ text: 'About us', href: 'https://example.com/about' }, { text: 'Careers', href: 'https://example.com/jobs', external: true }]) // siblings untouched
await h.press('Undo (Ctrl+Z)')
expect(h.cms.graph.node('f1').data.links[0].text).toBe('About')
})
it('markers follow the selection and are removed from the page when it moves on', async () => {
h = await setup()
await h.settle()
await select('f1')
expect(document.querySelectorAll('[data-cms-inferred]').length).toBe(3)
await select('t1')
expect(byId('f1').querySelectorAll('[data-cms-inferred]').length).toBe(0)
})
it('attribute-bound properties get a chip that edits them (href), nested paths included', async () => {
h = await setup()
await h.settle()
await select('f1')
const chips = qa('[aria-label="Edit href"]')
expect(chips).toHaveLength(2)
await act(async () => void chips[1].click())
await h.settle()
const input = q('.cms-dialog input') as HTMLInputElement
expect(input.value).toBe('https://example.com/jobs')
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'https://example.com/careers')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => void q('.cms-dialog .cms-btn--primary').click())
await h.settle()
expect(h.cms.graph.node('f1').data.links[1]).toEqual({ text: 'Careers', href: 'https://example.com/careers', external: true })
expect(byId('f1').querySelectorAll('a')[1].getAttribute('href')).toBe('https://example.com/careers')
})
it('explicit template markers keep working (and are not double-bound)', async () => {
h = await setup()
await h.settle()
await select('h1')
expect(byId('h1').getAttribute('data-cms-inferred')).toBeNull()
expect(byId('h1').getAttribute('data-cms-field')).toBe('text')
})
})
describe('block palette and drag & drop', () => {
it('lists the types that fit the page, searchable', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
const names = qa('.cms-block[data-type]').map((b) => b.dataset.type)
expect(names).toEqual(expect.arrayContaining(['heading', 'text', 'button', 'layout', 'footer']))
expect(names).not.toContain('page') // a page root is not a block you insert
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(q('[aria-label="Search blocks"]'), 'head')
q('[aria-label="Search blocks"]').dispatchEvent(new Event('input', { bubbles: true }))
})
expect(qa('.cms-block > span').map((s) => s.textContent)).toEqual(['Heading', 'Hero (heading + text + button)']) // title shown; the blueprint matches too
})
it('click adds to the selection: inside a container, otherwise after the block', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await select('s1') // container
await act(async () => void q('.cms-block[data-type="button"]').click())
await h.settle()
expect(kids('s1')).toHaveLength(4)
await select('t2') // leaf: goes after it, in s2
await act(async () => void q('.cms-block[data-type="heading"]').click())
await h.settle()
expect(h.cms.graph.node(kids('s2')[1]).dataType).toBe('heading')
})
it('drag from the palette shows a ghost at the target and inserts at the drop index', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
place('s1', 0, 0, 600, 300)
place('h1', 10, 10, 580, 50)
place('t1', 10, 70, 580, 100)
place('b1', 10, 180, 580, 40)
h.editor.ctrl.scan()
const item = q('.cms-block[data-type="heading"]')
await ptr('pointerdown', 50, 400, item)
await ptr('pointermove', 50, 400) // under the threshold: nothing yet
expect(h.editor.ctrl.store.get().drag).toBeNull()
await ptr('pointermove', 80, 100) // past it: drag starts (this event only arms it) ...
await ptr('pointermove', 80, 100) // ... and the next one targets s1
const drag = h.editor.ctrl.store.get().drag!
expect(drag).toMatchObject({ source: 'palette', type: 'heading', target: { parentId: 's1', index: 1 } })
expect(q('.cms-ghost').textContent).toContain('New Heading')
await ptr('pointerup', 80, 100)
await h.settle()
const order = kids('s1')
expect(order).toHaveLength(4)
expect(h.cms.graph.node(order[1]).dataType).toBe('heading')
expect(h.editor.ctrl.store.get().drag).toBeNull()
expect(h.shadow.querySelector('.cms-ghost')).toBeNull()
})
it('Escape cancels a drag; a container that does not accept the type refuses it', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
expect(h.editor.ctrl.store.get().drag).not.toBeNull()
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
expect(h.editor.ctrl.store.get().drag).toBeNull()
expect(h.editor.ctrl.accepts('s1', 'heading')).toBe(true)
expect(h.editor.ctrl.accepts('s1', 'page')).toBe(false)
expect(h.editor.ctrl.accepts('t1', 'heading')).toBe(false) // a leaf block holds nothing
expect(h.editor.ctrl.accepts('', 'anything')).toBe(true) // page root
})
it('moving an existing block uses the same engine and respects acceptance', async () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
place('h1', 10, 10, 580, 50)
place('t1', 10, 70, 580, 100)
place('b1', 10, 180, 580, 40)
h.editor.ctrl.scan()
h.editor.ctrl.beginDrag('b1')
await ptr('pointermove', 80, 20)
expect(h.editor.ctrl.store.get().drag).toMatchObject({ source: 'block', id: 'b1', target: { parentId: 's1', index: 0 } })
await ptr('pointerup', 80, 20)
await h.settle()
expect(kids('s1')).toEqual(['b1', 'h1', 't1'])
})
it('a slot that refuses the type names what it takes (drop still falls back to the container)', async () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
// The section schema declares a `sidebar` slot taking text/button/footer; the fixture template marks it.
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
zone.getBoundingClientRect = () => ({ x: 10, y: 190, left: 10, top: 190, right: 590, bottom: 250, width: 580, height: 60, toJSON() {} }) as DOMRect
h.editor.ctrl.scan()
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
await ptr('pointermove', 80, 200)
expect(h.editor.ctrl.store.get().drag).toMatchObject({ slot: { parentId: 's1', handle: 'sidebar' }, target: { parentId: 's1' } })
expect(h.shadow.querySelector('.cms-slotref')?.textContent).toContain('sidebar takes: text, button, footer')
expect(h.shadow.querySelector('.cms-dragchip')?.className).toContain('is-refused')
await ptr('pointerup', 80, 200)
await h.settle()
expect(h.editor.ctrl.store.get().drag).toBeNull()
})
it('a slot that accepts the type shows no refusal', async () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
const zone = byId('s1').querySelector<HTMLElement>('[data-cms-slot]')!
zone.getBoundingClientRect = () => ({ x: 10, y: 190, left: 10, top: 190, right: 590, bottom: 250, width: 580, height: 60, toJSON() {} }) as DOMRect
h.editor.ctrl.scan()
h.editor.ctrl.beginPaletteDrag({ type: 'button', label: 'button' })
await ptr('pointermove', 80, 200)
expect(h.editor.ctrl.store.get().drag).toMatchObject({
slot: { parentId: 's1', handle: 'sidebar' },
target: { parentId: 's1', handle: 'sidebar' },
})
expect(h.shadow.querySelector('.cms-slotref')).toBeNull()
await ptr('pointerup', 80, 200)
await h.settle()
})
it('holding the pointer at the viewport edge scrolls the page while dragging', async () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
h.editor.ctrl.scan()
const scrolled: Array<[number, number]> = []
const realScrollBy = window.scrollBy
window.scrollBy = ((dx: number, dy: number) => void scrolled.push([dx, dy])) as typeof window.scrollBy
try {
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
await ptr('pointermove', 400, window.innerHeight - 4)
await act(async () => void (await new Promise((r) => setTimeout(r, 40)))) // let the rAF autoscroll step run
expect(scrolled.length).toBeGreaterThan(0)
expect(scrolled[0]).toEqual([0, expect.any(Number)])
expect(scrolled[0][1]).toBeGreaterThan(0)
await ptr('pointerup', 400, window.innerHeight - 4)
await h.settle()
} finally {
window.scrollBy = realScrollBy
}
expect(h.editor.ctrl.store.get().drag).toBeNull()
})
})
describe('insertion slots (virtual "+" elements)', () => {
it('a selected container offers a slot between each child and at the end; a slot opens the picker at that index', async () => {
h = await setup()
await h.settle()
await select('s1')
expect(qa('.cms-slot').map((s) => s.getAttribute('aria-label'))).toEqual(['Add block at position 1', 'Add block at position 2', 'Add block at position 3', 'Add block at position 4', 'Add to sidebar'])
await act(async () => void qa('.cms-slot')[1].click())
await h.settle()
expect([...h.shadow.querySelectorAll('.cms-dialog h4')].map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints']) // grouped by x-graph.group
await act(async () => void q('.cms-dialog .cms-tile[data-type="button"]').click())
await h.settle()
expect(h.cms.graph.node(kids('s1')[1]).dataType).toBe('button')
expect(kids('s1')).toHaveLength(4)
})
it('an empty container shows one big "Add block" slot', async () => {
h = await setup()
await h.settle()
await h.editor.ctrl.insert('s2', 'section')
await h.settle()
expect(qa('.cms-slot--empty')).toHaveLength(1)
expect(q('.cms-slot--empty').textContent).toContain('Add block')
})
it('leaf blocks show no slots', async () => {
h = await setup()
await h.settle()
await select('t1')
expect(qa('.cms-slot')).toHaveLength(0)
})
})
describe('sorting root nodes (snap between siblings)', () => {
const roots = () => {
place('s1', 0, 0, 600, 300)
place('h1', 10, 10, 580, 50)
place('t1', 10, 70, 580, 100)
place('b1', 10, 180, 580, 40)
place('s2', 0, 320, 600, 100)
place('t2', 10, 330, 580, 80)
place('l1', 0, 440, 600, 100) // a container: used to swallow the drop
place('x1', 10, 450, 280, 80)
place('x2', 310, 450, 280, 80)
place('f1', 0, 560, 600, 50)
h.editor.ctrl.scan()
}
const order = () => kids('p1')
it('dragging a root block over another container snaps between roots instead of nesting', async () => {
h = await setup()
await h.settle()
roots()
h.editor.ctrl.beginDrag('s2')
await ptr('pointermove', 80, 500) // deep inside the l1 container
const drag = h.editor.ctrl.store.get().drag!
expect(drag.target).toMatchObject({ parentId: '', index: 2 }) // siblings s1, l1, f1 -> between l1 and f1
expect(drag.target!.snaps).toHaveLength(4)
expect(h.shadow.querySelectorAll('.cms-snap')).toHaveLength(4)
expect(q('.cms-dragchip').textContent).toContain('sorting')
expect(q('.cms-dropline').style.top).toBe('548.5px') // centred in the 540..560 gap
await ptr('pointerup', 80, 500)
await h.settle()
expect(order()).toEqual(['s1', 'l1', 's2', 'f1'])
expect(kids('l1')).toEqual(['x1', 'x2']) // nothing was nested
})
it('the snap marker follows the nearest boundary as the pointer moves', async () => {
h = await setup()
await h.settle()
roots()
h.editor.ctrl.beginDrag('f1')
const idx = () => h.editor.ctrl.store.get().drag!.target!.index
await ptr('pointermove', 80, 20)
expect(idx()).toBe(0)
await ptr('pointermove', 80, 330)
expect(idx()).toBe(1)
await ptr('pointermove', 80, 450)
expect(idx()).toBe(2)
await ptr('pointerup', 80, 20)
await h.settle()
expect(order()).toEqual(['f1', 's1', 's2', 'l1'])
})
it('holding Alt nests into the container under the pointer; pressing/releasing Alt re-targets without a move', async () => {
h = await setup()
await h.settle()
roots()
h.editor.ctrl.beginDrag('s2')
await ptr('pointermove', 80, 500)
expect(h.editor.ctrl.store.get().drag!.target!.parentId).toBe('')
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', bubbles: true })))
expect(h.editor.ctrl.store.get().drag!.target).toMatchObject({ parentId: 'l1' })
expect(q('.cms-dragchip').textContent).toContain('nesting')
expect(h.shadow.querySelectorAll('.cms-snap')).toHaveLength(0)
await act(async () => void document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', bubbles: true })))
expect(h.editor.ctrl.store.get().drag!.target!.parentId).toBe('')
await ptr('pointermove', 80, 500, document, true) // moving with Alt held nests
await ptr('pointerup', 80, 500, document, true)
await h.settle()
expect(kids('l1')).toContain('s2')
expect(order()).toEqual(['s1', 'l1', 'f1'])
})
it('dropping a root block where it already is changes nothing; over editor chrome there is no target', async () => {
h = await setup()
await h.settle()
roots()
const edits = () => h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit').length
const before = edits()
h.editor.ctrl.beginDrag('s2')
await ptr('pointermove', 80, 300) // nearest boundary is its own slot
await ptr('pointerup', 80, 300)
await h.settle()
expect(edits()).toBe(before)
h.editor.ctrl.beginDrag('s2')
await ptr('pointermove', 900, 300) // beside the page: over the rail / panels
expect(h.editor.ctrl.store.get().drag!.target).toBeNull()
expect(q('.cms-dragchip').textContent).toContain('not here')
await ptr('pointerup', 900, 300)
await h.settle()
expect(edits()).toBe(before)
})
it('nested blocks sort among their siblings the same way, in a row too', async () => {
h = await setup()
await h.settle()
roots()
h.editor.ctrl.beginDrag('x1')
await ptr('pointermove', 520, 480) // right half of the row, over x2
const t = h.editor.ctrl.store.get().drag!.target!
expect(t).toMatchObject({ parentId: 'l1', index: 1 }) // after x2 (siblings without x1: x2 only)
expect(t.line.w).toBe(3) // a vertical line: the siblings are side by side
await ptr('pointerup', 520, 480)
await h.settle()
expect(kids('l1')).toEqual(['x2', 'x1'])
})
})
describe('dropzone highlights', () => {
const zones = () => qa('.cms-dropzone:not(.cms-dropzone--slot)').map((el) => el.getAttribute('data-dropzone'))
const slots = () => qa('.cms-dropzone--slot')
const stop = async () => {
await act(async () => void h.editor.ctrl.cancelDrag())
await h.settle()
}
it('a palette drag outlines every container the type fits, and clears on drop/cancel', async () => {
h = await setup()
await h.settle()
expect(qa('.cms-dropzone').length).toBe(0) // idle: no highlights
h.editor.ctrl.beginPaletteDrag({ type: 'text', label: 'Text' })
await h.settle()
const ids = zones()
expect(ids).toContain('s1') // section takes text as a plain child
expect(ids).toContain('s2')
expect(ids).toContain('l1') // layout takes text too
expect(ids).not.toContain('t1') // leaves are never dropzones
const s = slots()
expect(s.map((el) => el.getAttribute('data-dropzone'))).toEqual(['slot:s1:sidebar', 'slot:s2:sidebar']) // both sections mark their sidebar slot
expect(s.every((el) => !el.className.includes('is-refused'))).toBe(true) // sidebar takes text
await stop()
expect(qa('.cms-dropzone').length).toBe(0)
})
it('a type that only fits through a slot still lights up the container', async () => {
h = await setup()
await h.settle()
expect(h.editor.ctrl.accepts('s1', 'footer')).toBe(false) // not a plain child ...
expect(h.editor.ctrl.handleForDrop('s1', 'footer')).toBe('sidebar') // ... but fits the slot
h.editor.ctrl.beginPaletteDrag({ type: 'footer', label: 'Footer' })
await h.settle()
expect(zones()).toContain('s1') // the drop lands in its slot, so it glows
await stop()
})
it('a blueprint drag lights up containers and marks every slot refused', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.scan()
h.editor.ctrl.beginPaletteDrag({ blueprint: 'hero', label: 'Hero' })
await h.settle()
expect(zones()).toContain('s1') // typeless: every container fits
const s = slots()
expect(s.map((el) => el.getAttribute('data-dropzone'))).toEqual(['slot:s1:sidebar', 'slot:s2:sidebar'])
expect(s[0].className).toContain('is-refused') // blueprints cannot land in slots
expect(s[0].querySelector('.cms-dropzone__tag')!.textContent).toBe('sidebar')
await stop()
})
it('a node drag accepts a fitting slot and refuses one that does not take the type', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.scan()
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'text', label: 'sparrows' })
await h.settle()
expect(slots()).toHaveLength(2)
expect(slots().every((el) => !el.className.includes('is-refused'))).toBe(true) // sidebar takes text
await stop()
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'heading', label: 'headline' })
await h.settle()
expect(slots()).toHaveLength(2)
expect(slots().every((el) => el.className.includes('is-refused'))).toBe(true) // sidebar takes text/button/footer
expect(zones()).toContain('s1') // ... while the section itself still takes it
await stop()
})
it('moving a block excludes itself (and never highlights leaves)', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.beginDrag('s1')
await h.settle()
const ids = zones()
expect(ids).not.toContain('s1') // cannot drop into itself
expect(ids).toEqual(expect.arrayContaining(['s2', 'l1'])) // sections nest, so siblings still take it
expect(ids).not.toContain('h1') // leaves are never dropzones
await stop()
})
it('zones match once per drag: scroll ticks re-measure but do not re-match', async () => {
h = await setup()
await h.settle()
const ctrl = h.editor.ctrl
let n = 0
const orig = ctrl.accepts
ctrl.accepts = (...args: [string, string]) => {
n++
return orig.call(ctrl, ...args)
}
try {
ctrl.beginPaletteDrag({ type: 'text', label: 'Text' })
await h.settle()
expect(n).toBeGreaterThan(0) // matching ran: every container checked
expect(qa('.cms-dropzone').length).toBeGreaterThan(0)
ctrl.store.set({ tick: ctrl.store.get().tick + 1 }) // scroll/resize/mutation tick
await h.settle()
expect(qa('.cms-dropzone').length).toBeGreaterThan(0) // still drawn, rects re-measured ...
const after = n
ctrl.store.set({ tick: ctrl.store.get().tick + 1 })
await h.settle()
expect(n).toBe(after) // ... without re-running the accept predicates
await stop()
} finally {
ctrl.accepts = orig
}
})
it('slot tags show handles as words', () => {
expect(slotLabel('sidebar')).toBe('sidebar')
expect(slotLabel('3d-accessories')).toBe('3d accessories')
expect(slotLabel('hero_slot')).toBe('hero slot')
})
})