Files
cms-onpageeditor/src/form/ArrayField.tsx
T
2026-11-01 10:27:56 +01:00

185 lines
8.4 KiB
TypeScript

import { useRef, useState } from 'react'
import type { JSONSchema } from '../api'
import { Icon } from '../ui/Icon'
import { useForm } from './context'
import { JsonArea, Widget } from './Form'
import { defaultFor, resolveSchema, variantsOf } from './resolve'
let uid = 0
const newKey = () => `k${++uid}`
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
/** Short label for a collapsed object card: x-label, else the first useful string. */
function summarise(root: JSONSchema, itemSchema: JSONSchema, item: unknown, index: number): string {
if (typeof item === 'string' || typeof item === 'number') return String(item)
if (item && typeof item === 'object') {
const o = item as Record<string, unknown>
const label = itemSchema['x-label'] ?? itemSchema['x-graph']?.label
const prefer = [label, 'title', 'name', 'label', 'text', 'heading', 'href', 'id'].filter(Boolean) as string[]
for (const k of prefer) if (typeof o[k] === 'string' && o[k]) return String(o[k]).slice(0, 48)
for (const v of Object.values(o)) if (typeof v === 'string' && v) return v.slice(0, 48)
}
return `Item ${index + 1}`
}
export function ArrayField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
const { root, errors } = useForm()
const items = Array.isArray(value) ? value : []
const it = resolveSchema(root, Array.isArray(s.items) ? undefined : s.items)
const itemSchema = it.schema
// Tuples / untyped arrays have no per-item form: raw JSON is the honest editor.
if (Array.isArray(s.items) || s.prefixItems) return <JsonArea value={value} onChange={onChange} />
// uniqueItems + enum items: a checkbox set (the natural UI for "pick several").
if (Array.isArray(itemSchema.enum) && s.uniqueItems) {
return (
<div className="cms-checks">
{itemSchema.enum.map((o: unknown) => (
<label key={String(o)} className="cms-inline">
<input
type="checkbox"
className="cms-check"
checked={items.includes(o)}
onChange={(e) => onChange(e.target.checked ? [...items, o] : items.filter((x) => x !== o))}
/>{' '}
{String(o)}
</label>
))}
</div>
)
}
const t = Array.isArray(itemSchema.type) ? itemSchema.type[0] : itemSchema.type
const variants = variantsOf(itemSchema)
const compound = t === 'object' || t === 'array' || (!t && !!itemSchema.properties) || !!variants?.some((v) => resolveSchema(root, v).schema.type === 'object')
return <ArrayList schema={s} itemSchema={itemSchema} items={items} onChange={onChange} path={path} compound={compound} err={errors[path]} />
}
function ArrayList({ schema: s, itemSchema, items, onChange, path, compound }: { schema: JSONSchema; itemSchema: JSONSchema; items: unknown[]; onChange(v: unknown): void; path: string; compound: boolean; err?: string }) {
const { root } = useForm()
// Stable keys survive reorders, so open/closed state and focus stay with the item.
const keys = useRef<string[]>([])
while (keys.current.length < items.length) keys.current.push(newKey())
if (keys.current.length > items.length) keys.current.length = items.length
const [open, setOpen] = useState<Record<string, boolean>>({})
const [dragFrom, setDragFrom] = useState<number | null>(null)
const [dragOver, setDragOver] = useState<number | null>(null)
const min = s.minItems ?? 0
const max = s.maxItems ?? Infinity
const isOpen = (k: string) => open[k] ?? items.length <= 2
const commit = (next: unknown[]) => onChange(next)
const move = (from: number, to: number) => {
if (to < 0 || to >= items.length || from === to) return
const next = [...items]
next.splice(to, 0, ...next.splice(from, 1))
keys.current.splice(to, 0, ...keys.current.splice(from, 1))
commit(next)
}
const remove = (i: number) => {
keys.current.splice(i, 1)
commit(items.filter((_, j) => j !== i))
}
const duplicate = (i: number) => {
const next = [...items]
next.splice(i + 1, 0, structuredClone(items[i]))
keys.current.splice(i + 1, 0, newKey())
commit(next)
}
const add = () => {
if (items.length >= max) return
const k = newKey()
keys.current.push(k)
setOpen((o) => ({ ...o, [k]: true }))
commit([...items, defaultFor(root, itemSchema)])
}
return (
<div className="cms-array" data-count={items.length}>
<ol className="cms-array__list">
{items.map((item, i) => {
const k = keys.current[i]
const expanded = !compound || isOpen(k)
return (
<li
key={k}
className={`cms-item${dragOver === i && dragFrom !== null && dragFrom !== i ? ' is-over' : ''}`}
onDragOver={(e) => {
if (dragFrom === null) return
e.preventDefault()
setDragOver(i)
}}
onDrop={(e) => {
e.preventDefault()
if (dragFrom !== null) move(dragFrom, i)
setDragFrom(null)
setDragOver(null)
}}
>
<div className="cms-item__head">
<span
className="cms-grip"
draggable
title="Drag to reorder"
aria-hidden="true"
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(i))
setDragFrom(i)
}}
onDragEnd={() => (setDragFrom(null), setDragOver(null))}
>
<Icon name="grip" size={18} />
</span>
{compound ? (
<button type="button" className="cms-item__title" aria-expanded={expanded} onClick={() => setOpen((o) => ({ ...o, [k]: !expanded }))}>
<Icon name="chevron" size={16} className={`cms-chev${expanded ? ' is-open' : ''}`} />
<span className="cms-item__text">{summarise(root, itemSchema, item, i)}</span>
</button>
) : (
<div className="cms-item__value">
<Widget schema={itemSchema} value={item} onChange={(v) => commit(items.map((x, j) => (j === i ? (v === undefined ? defaultFor(root, itemSchema) : v) : x)))} path={`${path}.${i}`} required />
</div>
)}
<span className="cms-item__actions">
<button type="button" className="cms-ico" aria-label={`Move item ${i + 1} up`} title="Move up" disabled={i === 0} onClick={() => move(i, i - 1)}>
<Icon name="up" size={16} />
</button>
<button type="button" className="cms-ico" aria-label={`Move item ${i + 1} down`} title="Move down" disabled={i === items.length - 1} onClick={() => move(i, i + 1)}>
<Icon name="down" size={16} />
</button>
{compound && (
<button type="button" className="cms-ico" aria-label={`Duplicate item ${i + 1}`} title="Duplicate" disabled={items.length >= max} onClick={() => duplicate(i)}>
<Icon name="duplicate" size={16} />
</button>
)}
<button type="button" className="cms-ico cms-ico--danger" aria-label={`Remove item ${i + 1}`} title="Remove" disabled={items.length <= min} onClick={() => remove(i)}>
<Icon name="close" size={16} />
</button>
</span>
</div>
{compound && expanded && (
<div className="cms-item__body">
<Widget schema={itemSchema} value={item} onChange={(v) => commit(items.map((x, j) => (j === i ? (v ?? {}) : x)))} path={`${path}.${i}`} required bare />
</div>
)}
</li>
)
})}
</ol>
<div className="cms-array__foot">
<button type="button" className="cms-btn" aria-label="Add item" onClick={add} disabled={items.length >= max}>
<Icon name="add" size={16} /> Add {itemSchema.title?.toLowerCase() ?? 'item'}
</button>
<span className="cms-muted">
{plural(items.length, 'item')}
{s.minItems ? ` · min ${s.minItems}` : ''}
{s.maxItems ? ` · max ${s.maxItems}` : ''}
</span>
</div>
</div>
)
}