Style panel gets a real-viewport iframe preview (390/820/1280px) tied to the breakpoint tab, reloaded after each edit, and spacing/gap controls per breakpoint. The editor does not boot inside the preview frame. Mock and tests mirror the new k6n layout template. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
214 lines
9.8 KiB
TypeScript
214 lines
9.8 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { act } from 'react'
|
|
import { fireEvent } from '@testing-library/react'
|
|
import { byId, click, setup } from './harness'
|
|
import type { Harness } from './harness'
|
|
|
|
let h: Harness
|
|
afterEach(() => h?.cleanup())
|
|
|
|
const select = async (id: string) => {
|
|
await act(async () => void click(byId(id)))
|
|
await h.settle()
|
|
}
|
|
const data = (id: string) => h.cms.graph.node(id).data
|
|
const editOps = () => h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit').map((l) => (l.request as any).ops)
|
|
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
|
|
const chip = (field: string, label: string) => [...h.shadow.querySelectorAll<HTMLElement>(`[data-style-field="${field}"] .cms-chip`)].find((c) => c.textContent!.includes(label))!
|
|
|
|
describe('Style panel (schema-driven)', () => {
|
|
it('loads schemas in the background and matches editors on the schema, not the node type', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
expect(h.editor.ctrl.schemaOf('layout')).toBeDefined()
|
|
expect(h.editor.ctrl.wrapperType()).toBe('layout')
|
|
await select('l1')
|
|
expect(h.shadow.querySelector('[aria-label="Style"]')).not.toBeNull()
|
|
await select('t1') // a text block has no style fields
|
|
expect(h.shadow.querySelector('[aria-label="Style"]')).toBeNull()
|
|
})
|
|
|
|
it('chips write one token through set; reset clears it; the page re-renders with the new class', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
await h.press('Style')
|
|
expect(q('[data-style-field="gap"] .cms-chip.is-on').textContent).toBe('md')
|
|
await act(async () => void chip('gap', 'lg').click())
|
|
await h.settle()
|
|
expect(data('l1').gap).toBe('lg')
|
|
expect(byId('l1').className).toContain('k6n-gap-lg')
|
|
expect(editOps().at(-1)[0]).toMatchObject({ op: 'set', id: 'l1', data: { gap: 'lg' } })
|
|
await act(async () => void q('[aria-label="Reset Gap"]').click())
|
|
await h.settle()
|
|
expect('gap' in data('l1')).toBe(false)
|
|
})
|
|
|
|
it('flow chips hide legacy aliases; columns shows only for grid-like flows', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
await h.press('Style')
|
|
const labels = [...h.shadow.querySelectorAll('[data-style-field="flow"] .cms-chip')].map((c) => c.textContent)
|
|
expect(labels).toEqual(['Row', 'Stack', 'Actions'])
|
|
expect(h.shadow.querySelector('[data-style-field="columns"]')).not.toBeNull()
|
|
await act(async () => void chip('flow', 'Stack').click())
|
|
await h.settle()
|
|
expect(h.shadow.querySelector('[data-style-field="columns"]')).toBeNull()
|
|
})
|
|
|
|
it('box model: per-side selects, overrides show the inherited value, shared fields appear once', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
await h.press('Style')
|
|
const sel = (label: string) => h.shadow.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`)
|
|
// l1: padding sm, paddingTop lg (own), margin sm
|
|
expect(sel('padding top')!.value).toBe('lg')
|
|
expect(sel('padding bottom')!.value).toBe('') // inherits
|
|
expect(sel('padding bottom')!.options[0].text).toBe('sm (padding)')
|
|
expect(sel('padding all sides')!.value).toBe('sm') // one select for left+right, other edge shows a link
|
|
expect(h.shadow.querySelectorAll('.cms-box__link').length).toBeGreaterThan(0)
|
|
await act(async () => void fireEvent.change(sel('padding bottom')!, { target: { value: 'xl' } }))
|
|
await h.settle()
|
|
expect(data('l1').paddingBottom).toBe('xl')
|
|
await act(async () => void fireEvent.change(sel('padding top')!, { target: { value: '' } }))
|
|
await h.settle()
|
|
expect('paddingTop' in data('l1')).toBe(false)
|
|
expect(sel('padding top')!.options[0].text).toBe('sm (padding)')
|
|
})
|
|
|
|
it('breakpoint tabs edit responsive.<bp>.* and leave the base alone', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
await h.press('Style')
|
|
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Tablet')!.click())
|
|
await act(async () => void chip('flow', 'Stack').click())
|
|
await h.settle()
|
|
expect(data('l1').responsive).toEqual({ tablet: { flow: 'stack' } })
|
|
expect(data('l1').flow).toBe('row')
|
|
expect(byId('l1').className).toContain('k6n-layout--t-stack')
|
|
await act(async () => void q('[aria-label="Reset Flow"]').click())
|
|
await h.settle()
|
|
expect('responsive' in data('l1')).toBe(false)
|
|
})
|
|
|
|
it('breakpoints can override gap, which renders as a k6n-<bp>-gap class', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
await h.press('Style')
|
|
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Mobile')!.click())
|
|
await act(async () => void chip('gap', 'xs').click())
|
|
await h.settle()
|
|
expect(data('l1').responsive).toEqual({ mobile: { gap: 'xs' } })
|
|
expect(data('l1').gap).not.toBe('xs')
|
|
expect(byId('l1').className).toContain('k6n-m-gap-xs')
|
|
})
|
|
|
|
it('the device preview is an iframe named so the editor does not boot inside it', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
await h.press('Style')
|
|
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Mobile')!.click())
|
|
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('.cms-style__preview .cms-chip')][0].click())
|
|
const f = h.shadow.querySelector('iframe')!
|
|
expect(f.name).toBe('cms-device-preview')
|
|
expect(f.style.width).toBe('390px')
|
|
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Close preview"]')!.click())
|
|
expect(h.shadow.querySelector('iframe')).toBeNull()
|
|
})
|
|
|
|
it('rapid clicks on one field coalesce into a single undo step', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
await h.press('Style')
|
|
for (const t of ['sm', 'lg', 'xl']) await act(async () => void chip('gap', t).click())
|
|
await h.settle()
|
|
expect(data('l1').gap).toBe('xl')
|
|
await h.press('Undo (Ctrl+Z)')
|
|
expect(data('l1').gap).toBe('md') // back to before the first click
|
|
})
|
|
})
|
|
|
|
describe('wrap in layout (give any block spacing)', () => {
|
|
it('wraps in one atomic batch, opens the Style panel, and undoes in one step', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('t2')
|
|
const edits = editOps().length
|
|
await h.press('Wrap in layout')
|
|
await h.settle()
|
|
const wrapper = byId('t2').closest('[data-cms-type="layout"]')!
|
|
expect(wrapper).not.toBeNull()
|
|
expect(wrapper.parentElement!.closest('[data-cms]')!.getAttribute('data-cms')).toBe('s2') // took t2's place
|
|
expect(editOps().length).toBe(edits + 1)
|
|
expect(editOps().at(-1).map((o: any) => o.op)).toEqual(['insert', 'move'])
|
|
expect(h.editor.ctrl.store.get().selected).toBe(wrapper.getAttribute('data-cms'))
|
|
expect(h.shadow.querySelector('.cms-dialog')!.textContent).toContain('Style')
|
|
await h.press('Undo (Ctrl+Z)')
|
|
expect(byId('t2').closest('[data-cms-type="layout"]')).toBeNull()
|
|
expect(byId('t2').parentElement!.getAttribute('data-cms')).toBe('s2')
|
|
})
|
|
|
|
it('is not offered for blocks that already have style fields', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
expect(h.shadow.querySelector('[aria-label="Wrap in layout"]')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('on-page spacing handles', () => {
|
|
const grips = () => [...h.shadow.querySelectorAll<HTMLElement>('.cms-grip-h')]
|
|
const ptr = (el: Element, type: string, x: number, y: number) => act(async () => void el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y })))
|
|
|
|
it('draws grips only for what the selected block can express', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
await select('l1')
|
|
const labels = grips().map((g) => g.getAttribute('aria-label')!.split(':')[0])
|
|
expect(labels).toEqual(expect.arrayContaining(['padding top', 'padding bottom', 'padding (all sides)', 'margin top', 'margin bottom', 'margin (all sides)']))
|
|
await select('t1')
|
|
expect(grips()).toHaveLength(0)
|
|
})
|
|
|
|
it('dragging snaps to a token, previews live, and commits a single coalesced set', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
// jsdom has no layout/CSS vars: give the page a measured scale and a computed padding-top of 32px.
|
|
const px: Record<string, number> = { none: 0, xs: 8, sm: 16, md: 32, lg: 64, xl: 96 }
|
|
h.editor.ctrl.measure = (v) => px[v.replace('--k6n-sp-', '')] ?? null
|
|
byId('l1').style.paddingTop = '32px'
|
|
await select('l1')
|
|
const top = () => grips().find((g) => g.getAttribute('aria-label')!.startsWith('padding top'))!
|
|
expect(top().getAttribute('aria-label')).toBe('padding top: 32px')
|
|
await ptr(top(), 'pointerdown', 50, 100)
|
|
await ptr(top(), 'pointermove', 50, 140) // +40px -> 72 -> nearest lg (64)
|
|
expect(byId('l1').style.getPropertyValue('padding-top')).toBe('64px') // live preview
|
|
expect(h.shadow.querySelector('.cms-tip')!.textContent).toContain('lg')
|
|
await ptr(top(), 'pointerup', 50, 140)
|
|
await h.settle()
|
|
expect(data('l1').paddingTop).toBe('lg') // paddingTop, not the shared padding
|
|
expect(data('l1').padding).toBe('sm')
|
|
expect(byId('l1').className).toContain('k6n-pt-lg')
|
|
expect(byId('l1').style.getPropertyValue('padding-top')).toBe('') // preview removed
|
|
})
|
|
|
|
it('a click without movement changes nothing; Escape/cancel removes the preview', async () => {
|
|
h = await setup()
|
|
await h.settle()
|
|
h.editor.ctrl.measure = () => 10
|
|
await select('l1')
|
|
const before = editOps().length
|
|
const g = grips()[0]
|
|
await ptr(g, 'pointerdown', 10, 10)
|
|
await ptr(g, 'pointerup', 10, 10)
|
|
await h.settle()
|
|
expect(editOps().length).toBe(before)
|
|
})
|
|
})
|