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>
This commit is contained in:
mats committed 2026-11-09 09:35:16 +01:00
1 parent a798bc1ad3
commit 7362e4730f
5 files changed
+114 -52

No files matched your search

+45 -12
View File
@@ -3,6 +3,7 @@ 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
@@ -204,8 +205,7 @@ describe('block palette and drag & drop', () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
// The section schema declares a `sidebar` slot taking text/button/footer; the template marks it.
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
// 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()
@@ -223,7 +223,6 @@ describe('block palette and drag & drop', () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
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()
@@ -267,7 +266,7 @@ describe('insertion slots (virtual "+" elements)', () => {
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'])
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
@@ -419,7 +418,9 @@ describe('dropzone highlights', () => {
expect(ids).toContain('s2')
expect(ids).toContain('l1') // layout takes text too
expect(ids).not.toContain('t1') // leaves are never dropzones
expect(slots().length).toBe(0) // the fixture page has no tagged slots
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)
})
@@ -438,13 +439,12 @@ describe('dropzone highlights', () => {
it('a blueprint drag lights up containers and marks every slot refused', async () => {
h = await setup()
await h.settle()
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
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'])
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()
@@ -453,17 +453,16 @@ describe('dropzone highlights', () => {
it('a node drag accepts a fitting slot and refuses one that does not take the type', async () => {
h = await setup()
await h.settle()
byId('s1').insertAdjacentHTML('beforeend', '<div data-cms-slot="sidebar" data-cms-slot-parent="s1"></div>')
h.editor.ctrl.scan()
h.editor.ctrl.beginExistingDrag({ id: 'orphan1', type: 'text', label: 'sparrows' })
await h.settle()
expect(slots().length).toBe(1)
expect(slots()[0].className).not.toContain('is-refused') // sidebar takes text
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().length).toBe(1)
expect(slots()[0].className).toContain('is-refused') // sidebar takes text/button/footer
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()
})
@@ -479,4 +478,38 @@ describe('dropzone highlights', () => {
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')
})
})