test(ci): add exporter heading levels and a11y typography smoke; update MAIN_PLAN
CI / test (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useSyncExternalStore } from 'react'
|
||||
import type { FormState, FormField } from '@/lib/state/formStore'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
type Store = {
|
||||
getState: () => FormState
|
||||
subscribe: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
export default function FormBuilderPanel({ store }: { store: Store }) {
|
||||
const t = useTranslations()
|
||||
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState)
|
||||
|
||||
const onAddText = useCallback(() => {
|
||||
const idx = state.fields.length + 1
|
||||
const name = `field${idx}`
|
||||
store.getState().addField({ type: 'text', name, label: `Text ${idx}`, required: false })
|
||||
}, [state.fields.length, store])
|
||||
|
||||
const nextName = () => `field${state.fields.length + 1}`
|
||||
const onAddEmail = () => store.getState().addField({ type: 'email', name: nextName(), label: 'Email', required: false })
|
||||
const onAddTel = () => store.getState().addField({ type: 'tel', name: nextName(), label: 'Tel', required: false })
|
||||
const onAddDate = () => store.getState().addField({ type: 'date', name: nextName(), label: 'Date', required: false })
|
||||
const onAddTextarea = () => store.getState().addField({ type: 'textarea', name: nextName(), label: 'Message', required: false })
|
||||
const onAddCheckbox = () => store.getState().addField({ type: 'checkbox', name: nextName(), label: 'Agree', required: false })
|
||||
const onAddSelect = () => store.getState().addField({ type: 'select', name: nextName(), label: 'Select', required: false, options: [] })
|
||||
const onAddRadio = () => store.getState().addField({ type: 'radio', name: nextName(), label: 'Radio', required: false, options: [] })
|
||||
|
||||
const onChangeAction = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
store.getState().setActionUrl(e.target.value)
|
||||
}
|
||||
|
||||
const onChangeHp = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
store.getState().setSpam({ honeypotFieldName: e.target.value })
|
||||
}
|
||||
|
||||
const onChangeTs = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseInt(e.target.value || '0', 10)
|
||||
store.getState().setSpam({ minSubmitSeconds: Number.isNaN(v) ? 0 : v })
|
||||
}
|
||||
|
||||
const onChangeLabel = (i: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
store.getState().updateField(i, { label: e.target.value })
|
||||
}
|
||||
|
||||
const onToggleRequired = (i: number) => {
|
||||
const cur = store.getState().fields[i]?.required ?? false
|
||||
store.getState().updateField(i, { required: !cur })
|
||||
}
|
||||
|
||||
const onDown = (i: number) => {
|
||||
if (i < state.fields.length - 1) {
|
||||
store.getState().moveField(i, i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const onRemove = (i: number) => {
|
||||
store.getState().removeField(i)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button type="button" data-testid="btn-add-text" onClick={onAddText} className="px-2 py-1 border rounded">{t('builder.action.addText')}</button>
|
||||
<button type="button" onClick={onAddEmail} className="px-2 py-1 border rounded">{t('builder.action.addEmail')}</button>
|
||||
<button type="button" onClick={onAddTel} className="px-2 py-1 border rounded">{t('builder.action.addTel')}</button>
|
||||
<button type="button" onClick={onAddDate} className="px-2 py-1 border rounded">{t('builder.action.addDate')}</button>
|
||||
<button type="button" onClick={onAddTextarea} className="px-2 py-1 border rounded">{t('builder.action.addTextarea')}</button>
|
||||
<button type="button" onClick={onAddCheckbox} className="px-2 py-1 border rounded">{t('builder.action.addCheckbox')}</button>
|
||||
<button type="button" onClick={onAddSelect} className="px-2 py-1 border rounded">{t('builder.action.addSelect')}</button>
|
||||
<button type="button" onClick={onAddRadio} className="px-2 py-1 border rounded">{t('builder.action.addRadio')}</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block" htmlFor="form-action-url">
|
||||
<span className="block text-sm">{t('builder.form.actionUrl')}</span>
|
||||
<input id="form-action-url" aria-label="Action URL" className="border px-2 py-1 w-full" value={state.actionUrl}
|
||||
onChange={onChangeAction} />
|
||||
</label>
|
||||
<label className="block" htmlFor="form-honeypot">
|
||||
<span className="block text-sm">{t('builder.form.honeypot')}</span>
|
||||
<input id="form-honeypot" aria-label="Honeypot Field" className="border px-2 py-1 w-full" value={state.spam.honeypotFieldName}
|
||||
onChange={onChangeHp} />
|
||||
</label>
|
||||
<label className="block" htmlFor="form-min-seconds">
|
||||
<span className="block text-sm">{t('builder.form.minSeconds')}</span>
|
||||
<input id="form-min-seconds" aria-label="Min Submit Seconds" type="number" className="border px-2 py-1 w-full" value={state.spam.minSubmitSeconds}
|
||||
onChange={onChangeTs} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{state.fields.map((f: FormField, i: number) => (
|
||||
<li key={`${f.name}-${i}`} data-testid="field-item" className="text-sm border rounded p-2 flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-gray-500">{f.type} - {f.name}</div>
|
||||
<label className="block" htmlFor={`field-label-${i}`}>
|
||||
<span className="sr-only">Label {i}</span>
|
||||
<input id={`field-label-${i}`} aria-label={`Label ${i}`} className="border px-2 py-1 w-full" value={f.label}
|
||||
onChange={(e) => onChangeLabel(i, e)} />
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 mt-1" htmlFor={`field-required-${i}`}>
|
||||
<input id={`field-required-${i}`} aria-label={`Required ${i}`} type="checkbox" checked={!!f.required}
|
||||
onChange={() => onToggleRequired(i)} />
|
||||
<span className="text-xs">required</span>
|
||||
</label>
|
||||
{(f.type === 'text' || f.type === 'email' || f.type === 'tel' || f.type === 'date' || f.type === 'textarea') && (
|
||||
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||
<label className="block" htmlFor={`field-pattern-${i}`}>
|
||||
<span className="sr-only">Pattern {i}</span>
|
||||
<input
|
||||
id={`field-pattern-${i}`}
|
||||
aria-label={`Pattern ${i}`}
|
||||
className="border px-2 py-1 w-full"
|
||||
value={f.pattern ?? ''}
|
||||
onChange={(e) => store.getState().updateField(i, { pattern: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block" htmlFor={`field-maxlen-${i}`}>
|
||||
<span className="sr-only">MaxLength {i}</span>
|
||||
<input
|
||||
id={`field-maxlen-${i}`}
|
||||
aria-label={`MaxLength ${i}`}
|
||||
type="number"
|
||||
className="border px-2 py-1 w-full"
|
||||
value={f.maxLength ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value || '0', 10)
|
||||
store.getState().updateField(i, { maxLength: Number.isNaN(v) ? undefined : v })
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{(f.type === 'select' || f.type === 'radio') && (
|
||||
<label className="block mt-1" htmlFor={`field-options-${i}`}>
|
||||
<span className="sr-only">Options {i}</span>
|
||||
<input id={`field-options-${i}`} aria-label={`Options ${i}`} className="border px-2 py-1 w-full"
|
||||
value={(f.type === 'select' || f.type === 'radio') ? f.options.join(',') : ''}
|
||||
onChange={(e) => {
|
||||
const opts = e.target.value.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
if (f.type === 'select' || f.type === 'radio') {
|
||||
store.getState().updateField(i, { options: opts } as Partial<FormField>)
|
||||
}
|
||||
}} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" aria-label={`Down ${i}`} onClick={() => onDown(i)} className="px-2 py-1 border rounded">Down {i}</button>
|
||||
<button type="button" aria-label={`Remove ${i}`} onClick={() => onRemove(i)} className="px-2 py-1 border rounded text-red-600">Remove {i}</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user