테스트/린트 안정화: 버튼 쿼리 모호성 제거, 훅 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
+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 = [