테스트/린트 안정화: 버튼 쿼리 모호성 제거, 훅 deps 보강, vi.spyOn 모킹, 멀티선택 Moveable 간섭 방지 재확인, 스냅 우선순위 회귀 그린
Auto PR / open-pr (push) Successful in 21s
Auto Label PR / add-automerge-label (pull_request) Successful in 6s
CI / test (pull_request) Successful in 1m49s

This commit is contained in:
2025-11-17 17:33:39 +09:00
parent 1c607f6331
commit 5bf78b3e01
29 changed files with 1861 additions and 80 deletions
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { exportFrame } from '@/lib/wysiwyg/export'
import type { Frame } from '@/lib/wysiwyg/schema'
function baseFrame(): Frame {
return {
id: 'f', width: 400, height: 300, background: '#ffffff',
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
}
}
describe('wysiwyg export - forms aria-describedby(help)', () => {
it('input: adds help element and aria-describedby linkage', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'in1', type: 'input', x: 10, y: 20, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'email', label: 'Email', placeholder: '', required: false, help: 'We will not spam you.' } })
const out = exportFrame(f)
expect(out.html).toMatch(/<div id="in1">[\s\S]*<input id="in1-control" name="email" aria-describedby="in1-control-help"\/>[\s\S]*<small id="in1-control-help"/)
expect(out.html).toMatch(/We will not spam you\./)
})
it('select: adds help and aria-describedby', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'sel1', type: 'select', x: 0, y: 0, width: 100, height: 30, rotate: 0, zIndex: 0, props: { name: 'size', label: 'Size', options: ['S'], defaultValue: 'S', help: 'Choose wisely.' } })
const out = exportFrame(f)
expect(out.html).toMatch(/<select id="sel1-control" name="size" aria-describedby="sel1-control-help">/)
expect(out.html).toMatch(/<small id="sel1-control-help"/)
})
it('textarea: adds help and aria-describedby', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'ta1', type: 'textarea', x: 0, y: 0, width: 100, height: 60, rotate: 0, zIndex: 0, props: { name: 'msg', label: 'Msg', placeholder: '', rows: 3, required: false, help: 'Say hello.' } })
const out = exportFrame(f)
expect(out.html).toMatch(/<textarea id="ta1-control" name="msg" rows="3" aria-describedby="ta1-control-help"><\/textarea>/)
expect(out.html).toMatch(/<small id="ta1-control-help"/)
})
it('radio: adds group help (fieldset) and does not use aria-describedby per item by default', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'rg1', type: 'radio', x: 0, y: 0, width: 100, height: 60, rotate: 0, zIndex: 0, props: { name: 'color', legend: 'Color', options: ['Red'], defaultValue: 'Red', required: false, help: 'Pick one.' } })
const out = exportFrame(f)
expect(out.html).toMatch(/<fieldset id="rg1">[\s\S]*<legend>Color<\/legend>[\s\S]*<small id="rg1-help"/)
})
it('checkbox: adds group help (fieldset)', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'cb1', type: 'checkbox', x: 0, y: 0, width: 100, height: 60, rotate: 0, zIndex: 0, props: { name: 'pick', legend: 'Pick', options: ['One'], defaultValues: [], required: false, help: 'You can pick many.' } })
const out = exportFrame(f)
expect(out.html).toMatch(/<fieldset id="cb1">[\s\S]*<legend>Pick<\/legend>[\s\S]*<small id="cb1-help"/)
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import { exportFrame } from '@/lib/wysiwyg/export'
import type { Frame } from '@/lib/wysiwyg/schema'
function baseFrame(): Frame {
return {
id: 'f', width: 400, height: 300, background: '#ffffff',
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
}
}
describe('wysiwyg export - forms a11y', () => {
it('exports input with label/for and required/placeholder', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'in1', type: 'input', x: 10, y: 20, width: 260, height: 48, rotate: 0, zIndex: 0, props: { name: 'email', label: 'Email', placeholder: 'you@example.com', required: true } })
const out = exportFrame(f)
// label and input id/for
expect(out.html).toMatch(/<label for="in1-control">Email<\/label>/)
expect(out.html).toMatch(/<input id="in1-control" name="email"[^>]*placeholder="you@example.com"[^>]*required/)
// absolutely positioned wrapper
expect(out.css).toMatch(/#in1\s*{[^}]*left:10px;[^}]*top:20px;[^}]*width:260px;[^}]*height:48px/)
})
it('exports select with options and defaultValue', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'sel1', type: 'select', x: 12, y: 30, width: 220, height: 40, rotate: 0, zIndex: 0, props: { name: 'size', label: 'Size', options: ['S','M','L'], defaultValue: 'M' } })
const out = exportFrame(f)
expect(out.html).toMatch(/<label for="sel1-control">Size<\/label>/)
expect(out.html).toMatch(/<select id="sel1-control" name="size"/)
expect(out.html).toMatch(/<option value="S">S<\/option>/)
expect(out.html).toMatch(/<option value="M" selected>M<\/option>/)
expect(out.html).toMatch(/<option value="L">L<\/option>/)
})
it('exports textarea with label and rows/placeholder/required', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'ta1', type: 'textarea', x: 14, y: 40, width: 300, height: 90, rotate: 0, zIndex: 0, props: { name: 'message', label: 'Message', placeholder: 'Type...', rows: 5, required: true } })
const out = exportFrame(f)
expect(out.html).toMatch(/<label for="ta1-control">Message<\/label>/)
expect(out.html).toMatch(/<textarea id="ta1-control" name="message"[^>]*rows="5"[^>]*placeholder="Type..."[^>]*required/)
})
it('exports radio group with fieldset/legend and checked default', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'rg1', type: 'radio', x: 16, y: 60, width: 300, height: 80, rotate: 0, zIndex: 0, props: { name: 'color', legend: 'Color', options: ['Red','Green','Blue'], defaultValue: 'Green', required: true } })
const out = exportFrame(f)
expect(out.html).toMatch(/<fieldset id="rg1"/)
expect(out.html).toMatch(/<legend>Color<\/legend>/)
expect(out.html).toMatch(/<input type="radio" id="rg1-1" name="color" value="Green" checked required\/>/)
expect(out.html).toMatch(/<label for="rg1-1">Green<\/label>/)
})
it('exports checkbox group with fieldset/legend and defaultValues', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'cb1', type: 'checkbox', x: 18, y: 80, width: 320, height: 100, rotate: 0, zIndex: 0, props: { name: 'agreements', legend: 'Agree', options: ['One','Two','Three'], defaultValues: ['Two','Three'], required: true } })
const out = exportFrame(f)
expect(out.html).toMatch(/<fieldset id="cb1"/)
expect(out.html).toMatch(/<legend>Agree<\/legend>/)
expect(out.html).toMatch(/<input type="checkbox" id="cb1-1" name="agreements" value="Two" checked required\/>/)
expect(out.html).toMatch(/<label for="cb1-1">Two<\/label>/)
})
})
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { exportFrame } from '@/lib/wysiwyg/export'
import type { Frame } from '@/lib/wysiwyg/schema'
function baseFrame(): Frame {
return {
id: 'f', width: 400, height: 300, background: '#ffffff',
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
}
}
describe('wysiwyg export - form validation attributes', () => {
it('input: pattern, minLength, maxLength exported', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'in1', type: 'input', x: 0, y: 0, width: 200, height: 40, rotate: 0, zIndex: 0, props: { name: 'code', label: 'Code', placeholder: '', required: false, pattern: '[A-Z]{3}', minLength: 3, maxLength: 3 } })
const out = exportFrame(f)
expect(out.html).toMatch(/<input id="in1-control" name="code"[^>]*pattern="\[A-Z\]\{3\}"/)
expect(out.html).toMatch(/<input id="in1-control" name="code"[^>]*minlength="3"/i)
expect(out.html).toMatch(/<input id="in1-control" name="code"[^>]*maxlength="3"/i)
})
it('textarea: minLength, maxLength exported', () => {
const f = baseFrame()
f.layers[0].objects.push({ id: 'ta1', type: 'textarea', x: 0, y: 0, width: 200, height: 80, rotate: 0, zIndex: 0, props: { name: 'msg', label: 'Msg', placeholder: '', rows: 4, required: false, minLength: 10, maxLength: 200 } })
const out = exportFrame(f)
expect(out.html).toMatch(/<textarea id="ta1-control" name="msg"[^>]*minlength="10"/i)
expect(out.html).toMatch(/<textarea id="ta1-control" name="msg"[^>]*maxlength="200"/i)
})
})
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { exportFrame } from '@/lib/wysiwyg/export'
import type { Frame } from '@/lib/wysiwyg/schema'
function baseFrame(): Frame {
return {
id: 'f', width: 320, height: 200, background: '#111111',
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
}
}
describe('wysiwyg export - style tokens for controls', () => {
it('defines base styles for inputs/selects/textarea/fieldset/legend using tokens', () => {
const f = baseFrame()
const out = exportFrame(f)
expect(out.css).toMatch(/input,select,textarea\s*{[^}]*color:var\(--text\)/)
expect(out.css).toMatch(/input,select,textarea\s*{[^}]*border:1px solid var\(--border\)/)
expect(out.css).toMatch(/fieldset\s*{[^}]*border:1px solid var\(--border\)/)
expect(out.css).toMatch(/legend\s*{[^}]*color:var\(--text\)/)
})
})
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { exportFrame } from '@/lib/wysiwyg/export'
import type { Frame } from '@/lib/wysiwyg/schema'
function baseFrame(): Frame {
return {
id: 'f', width: 320, height: 200, background: '#111111',
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
}
}
describe('wysiwyg export - control state styles via tokens', () => {
it('adds :focus styles using tokens', () => {
const out = exportFrame(baseFrame())
expect(out.css).toMatch(/:root[^}]*--primary:/)
expect(out.css).toMatch(/input:focus,select:focus,textarea:focus\s*{[^}]*outline:2px solid var\(--primary\)/)
})
it('adds :invalid styles using tokens', () => {
const out = exportFrame(baseFrame())
expect(out.css).toMatch(/:root[^}]*--danger:/)
expect(out.css).toMatch(/input:invalid,select:invalid,textarea:invalid\s*{[^}]*border-color:var\(--danger\)/)
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { exportFrame } from '@/lib/wysiwyg/export'
import type { Frame } from '@/lib/wysiwyg/schema'
function baseFrame(): Frame {
return {
id: 'f', width: 320, height: 200, background: '#111111',
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
}
}
describe('wysiwyg export - style tokens', () => {
it('injects :root tokens and uses them in base rules', () => {
const f = baseFrame()
const out = exportFrame(f)
// root tokens
expect(out.css).toMatch(/:root\s*{[^}]*--bg:\s*#[0-9a-fA-F]{3,6}/)
expect(out.css).toMatch(/--text:/)
expect(out.css).toMatch(/--border:/)
expect(out.css).toMatch(/--primary:/)
// frame uses tokens
expect(out.css).toMatch(/\.frame\s*{[^}]*background:var\(--bg\)/)
// anchor uses primary color
expect(out.css).toMatch(/a\s*{[^}]*color:var\(--primary\)/)
})
})
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { exportFrame } from '@/lib/wysiwyg/export'
import type { Frame } from '@/lib/wysiwyg/schema'
function baseFrame(bg = '#111111'): Frame {
return {
id: 'f', width: 320, height: 200, background: bg,
layers: [{ id: 'l1', name: 'Base', visible: true, locked: false, objects: [] }],
}
}
describe('wysiwyg export - theme switching tokens', () => {
it('emits light/dark token presets', () => {
const out = exportFrame(baseFrame('#202020'))
expect(out.css).toMatch(/:root\s*{[^}]*--bg:/)
expect(out.css).toMatch(/\[data-theme="light"\]\s*{[^}]*--bg:/)
expect(out.css).toMatch(/\[data-theme="dark"\]\s*{[^}]*--bg:/)
})
it('frame uses var(--bg), so theme can override background', () => {
const out = exportFrame(baseFrame('#333333'))
expect(out.css).toMatch(/\.frame\s*{[^}]*background:var\(--bg\)/)
})
})
+91 -2
View File
@@ -9,6 +9,11 @@ function esc(s: string) {
function isImage(o: WObject): o is Extract<WObject, { type: 'image' }> { return o.type === 'image' }
function isText(o: WObject): o is Extract<WObject, { type: 'text' }> { return o.type === 'text' }
function isButton(o: WObject): o is Extract<WObject, { type: 'button' }> { return o.type === 'button' }
function isInput(o: WObject): o is Extract<WObject, { type: 'input' }> { return o.type === 'input' }
function isSelect(o: WObject): o is Extract<WObject, { type: 'select' }> { return o.type === 'select' }
function isTextarea(o: WObject): o is Extract<WObject, { type: 'textarea' }> { return o.type === 'textarea' }
function isRadio(o: WObject): o is Extract<WObject, { type: 'radio' }> { return o.type === 'radio' }
function isCheckbox(o: WObject): o is Extract<WObject, { type: 'checkbox' }> { return o.type === 'checkbox' }
function renderObject(o: WObject): { html: string; css: string } {
const baseCss = `#${o.id}{position:absolute;left:${o.x}px;top:${o.y}px;width:${o.width}px;height:${o.height}px;transform:rotate(${o.rotate}deg)}`
@@ -20,7 +25,82 @@ function renderObject(o: WObject): { html: string; css: string } {
const html = `<p id="${o.id}">${esc(o.props.text)}</p>`
return { html, css: baseCss + `;font-size:${o.props.fontSize}px;color:${o.props.color}` }
}
// button minimal
if (isInput(o)) {
const cid = `${o.id}-control`
const req = o.props.required ? ' required' : ''
const ph = o.props.placeholder ? ` placeholder="${esc(o.props.placeholder)}"` : ''
const helpId = o.props.help ? `${cid}-help` : ''
const described = helpId ? ` aria-describedby="${helpId}"` : ''
const pat = o.props.pattern ? ` pattern="${esc(o.props.pattern)}"` : ''
const min = typeof o.props.minLength === 'number' ? ` minlength="${o.props.minLength}"` : ''
const max = typeof o.props.maxLength === 'number' ? ` maxlength="${o.props.maxLength}"` : ''
const html = [
`<div id="${o.id}">`,
`<label for="${cid}">${esc(o.props.label)}</label>`,
`<input id="${cid}" name="${esc(o.props.name)}"${ph}${req}${described}${pat}${min}${max}/>`,
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
`</div>`,
].join('')
return { html, css: baseCss }
}
if (isSelect(o)) {
const cid = `${o.id}-control`
const opts = (o.props.options || [])
.map((opt) => `<option value="${esc(opt)}"${o.props.defaultValue === opt ? ' selected' : ''}>${esc(opt)}</option>`)
.join('')
const helpId = o.props.help ? `${cid}-help` : ''
const described = helpId ? ` aria-describedby="${helpId}"` : ''
const html = [
`<div id="${o.id}">`,
`<label for="${cid}">${esc(o.props.label)}</label>`,
`<select id="${cid}" name="${esc(o.props.name)}"${described}>${opts}</select>`,
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
`</div>`,
].join('')
return { html, css: baseCss }
}
if (isTextarea(o)) {
const cid = `${o.id}-control`
const req = o.props.required ? ' required' : ''
const ph = o.props.placeholder ? ` placeholder="${esc(o.props.placeholder)}"` : ''
const rows = Number(o.props.rows ?? 3)
const helpId = o.props.help ? `${cid}-help` : ''
const described = helpId ? ` aria-describedby="${helpId}"` : ''
const min = typeof o.props.minLength === 'number' ? ` minlength="${o.props.minLength}"` : ''
const max = typeof o.props.maxLength === 'number' ? ` maxlength="${o.props.maxLength}"` : ''
const html = [
`<div id="${o.id}">`,
`<label for="${cid}">${esc(o.props.label)}</label>`,
`<textarea id="${cid}" name="${esc(o.props.name)}" rows="${rows}"${ph}${req}${described}${min}${max}></textarea>`,
o.props.help ? `<small id="${helpId}">${esc(o.props.help)}</small>` : '',
`</div>`,
].join('')
return { html, css: baseCss }
}
if (isRadio(o)) {
const req = o.props.required ? ' required' : ''
const items = (o.props.options || []).map((opt, idx) => {
const id = `${o.id}-${idx}`
const checked = o.props.defaultValue === opt ? ' checked' : ''
return `<div><input type="radio" id="${id}" name="${esc(o.props.name)}" value="${esc(opt)}"${checked}${req}/><label for="${id}">${esc(opt)}</label></div>`
}).join('')
const help = o.props.help ? `<small id="${o.id}-help">${esc(o.props.help)}</small>` : ''
const html = [`<fieldset id="${o.id}">`, `<legend>${esc(o.props.legend)}</legend>`, items, help, `</fieldset>`].join('')
return { html, css: baseCss }
}
if (isCheckbox(o)) {
const req = o.props.required ? ' required' : ''
const def = new Set(o.props.defaultValues || [])
const items = (o.props.options || []).map((opt, idx) => {
const id = `${o.id}-${idx}`
const checked = def.has(opt) ? ' checked' : ''
return `<div><input type="checkbox" id="${id}" name="${esc(o.props.name)}" value="${esc(opt)}"${checked}${req}/><label for="${id}">${esc(opt)}</label></div>`
}).join('')
const help = o.props.help ? `<small id="${o.id}-help">${esc(o.props.help)}</small>` : ''
const html = [`<fieldset id="${o.id}">`, `<legend>${esc(o.props.legend)}</legend>`, items, help, `</fieldset>`].join('')
return { html, css: baseCss }
}
// button minimal (fallback)
const label = isButton(o) ? o.props.label : 'Button'
const href = isButton(o) ? (o.props.href || '#') : '#'
const html = `<a id="${o.id}" href="${esc(href)}">${esc(label)}</a>`
@@ -40,8 +120,17 @@ export function exportFrame(frame: Frame): Exported {
}
const inner = `<div class="frame-scale"><div class="frame">${piecesHtml.join('')}</div></div>`
const css = [
`.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:${frame.background}}`,
`:root{--bg:${frame.background};--text:#f9fafb;--border:#94a3b8;--primary:#0ea5e9;--danger:#ef4444}`,
`[data-theme="light"]{--bg:#ffffff;--text:#0f172a;--border:#cbd5e1;--primary:#0ea5e9;--danger:#ef4444}`,
`[data-theme="dark"]{--bg:#111111;--text:#f9fafb;--border:#94a3b8;--primary:#0ea5e9;--danger:#ef4444}`,
`.frame{position:relative;width:${frame.width}px;height:${frame.height}px;background:var(--bg)}`,
`.frame-scale{transform-origin:0 0}`,
`a{color:var(--primary);text-decoration:none}`,
`input,select,textarea{color:var(--text);background:transparent;border:1px solid var(--border)}`,
`input:focus,select:focus,textarea:focus{outline:2px solid var(--primary);outline-offset:2px}`,
`input:invalid,select:invalid,textarea:invalid{border-color:var(--danger)}`,
`fieldset{border:1px solid var(--border)}`,
`legend{color:var(--text)}`,
...piecesCss,
].join('\n')
const html = [
+64 -1
View File
@@ -39,7 +39,70 @@ const buttonObject = baseObject.extend({
}),
})
export const objectSchema = z.discriminatedUnion('type', [textObject, imageObject, buttonObject])
const inputObject = baseObject.extend({
type: z.literal('input'),
props: z.object({
name: z.string().min(1),
label: z.string().min(1),
placeholder: z.string().optional().default(''),
required: z.boolean().optional().default(false),
pattern: z.string().optional(),
minLength: z.number().int().nonnegative().optional(),
maxLength: z.number().int().nonnegative().optional(),
help: z.string().optional(),
}),
})
const selectObject = baseObject.extend({
type: z.literal('select'),
props: z.object({
name: z.string().min(1),
label: z.string().min(1),
options: z.array(z.string()).default([]),
defaultValue: z.string().optional().default(''),
help: z.string().optional(),
}),
})
const textareaObject = baseObject.extend({
type: z.literal('textarea'),
props: z.object({
name: z.string().min(1),
label: z.string().min(1),
placeholder: z.string().optional().default(''),
rows: z.number().int().positive().default(3),
required: z.boolean().optional().default(false),
minLength: z.number().int().nonnegative().optional(),
maxLength: z.number().int().nonnegative().optional(),
help: z.string().optional(),
}),
})
const radioObject = baseObject.extend({
type: z.literal('radio'),
props: z.object({
name: z.string().min(1),
legend: z.string().min(1),
options: z.array(z.string()).default([]),
defaultValue: z.string().optional().default(''),
required: z.boolean().optional().default(false),
help: z.string().optional(),
}),
})
const checkboxObject = baseObject.extend({
type: z.literal('checkbox'),
props: z.object({
name: z.string().min(1),
legend: z.string().min(1),
options: z.array(z.string()).default([]),
defaultValues: z.array(z.string()).default([]),
required: z.boolean().optional().default(false),
help: z.string().optional(),
}),
})
export const objectSchema = z.discriminatedUnion('type', [textObject, imageObject, buttonObject, inputObject, selectObject, textareaObject, radioObject, checkboxObject])
export type WObject = z.infer<typeof objectSchema>
export const layerSchema = z.object({