feat(admin): render mltextarea fields per locale

- MLField.vue is the one locale-switching wrapper; MLTextField,
  MLMarkdownField and the new MLTextareaField are shells around it
- isMLFieldType replaces the inline ML type-name checks in formState,
  FormView and RelationChildModal
- register mltextarea as a group-labelled control; rebuild dist
This commit is contained in:
Jakub Zych
2026-10-06 21:36:04 +02:00
parent 3596df4ff5
commit a913eec50f
15 changed files with 275 additions and 162 deletions

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
// The one locale-switching wrapper of the ML controls (mltext, mltextarea,
// mlmarkdown). It owns the locale select, follows ML_LOCALE_CHANGE from every
// other ML control, edits only the active locale and emits the whole map. The
// inner control (TextField, TextareaField, MarkdownField) is passed in and
// edits a plain string.
import { computed, inject, onMounted, onUnmounted, ref, type Component } from 'vue'
import type { FieldControlProps } from '../control'
import { FORM_ENABLED_LOCALES } from '../formContext'
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
interface MLFieldProps extends FieldControlProps {
/** The control that edits the active locale's text. */
inner: Component
/** The field type the inner control receives (text, textarea, markdown). */
innerType: string
/** The ML field type, shown on the root as data-ml-field. */
mlType: string
}
const props = defineProps<MLFieldProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
const values = computed(() => localeRecord(props.modelValue))
const enabledLocales = inject(FORM_ENABLED_LOCALES, null)
const locales = computed(() => {
const codes = enabledLocales?.value
return codes && codes.length > 0 ? [...codes] : []
})
const activeLocale = ref(locales.value[0] ?? 'en')
const labelledBy = computed(() => `${props.controlId}-label`)
const editorId = computed(() => `${props.controlId}-editor`)
const activeText = computed(() => values.value[activeLocale.value] ?? '')
// A stable field object for the inner control: it keeps size and attributes.
const innerField = computed(() => ({ ...props.field, type: props.innerType }))
function setLocale(locale: string) {
if (!locale) {
return
}
activeLocale.value = locale
}
function onLocaleInput(event: Event) {
const locale = (event.target as HTMLSelectElement).value
setLocale(locale)
broadcastMLLocale(locale)
}
function onText(text: string) {
emit('update:modelValue', { ...values.value, [activeLocale.value]: text })
}
function onBroadcast(event: Event) {
const locale = (event as CustomEvent<string>).detail
if (typeof locale === 'string' && locale !== '') {
setLocale(locale)
}
}
onMounted(() => {
window.addEventListener(ML_LOCALE_CHANGE, onBroadcast)
})
onUnmounted(() => {
window.removeEventListener(ML_LOCALE_CHANGE, onBroadcast)
})
</script>
<template>
<div class="flex flex-col gap-1.5" role="group" :aria-labelledby="labelledBy" :data-ml-field="mlType">
<div class="flex flex-wrap items-center gap-2">
<select
data-ml-locale
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
:value="activeLocale"
:aria-label="`Locale for ${field.label || field.name}`"
@change="onLocaleInput"
>
<option v-for="code in locales" :key="code" :value="code">{{ code }}</option>
</select>
</div>
<component
:is="inner"
:field="innerField"
:model-value="activeText"
:control-id="editorId"
:invalid="invalid"
:described-by="describedBy"
@update:model-value="onText"
/>
</div>
</template>

View File

@@ -1,78 +1,19 @@
<script setup lang="ts">
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
// The mlmarkdown control: MLField around MarkdownField.
import type { FieldControlProps } from '../control'
import MLField from './MLField.vue'
import MarkdownField from './MarkdownField.vue'
import { FORM_ENABLED_LOCALES } from '../formContext'
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
const values = computed(() => localeRecord(props.modelValue))
const enabledLocales = inject(FORM_ENABLED_LOCALES, null)
const locales = computed(() => {
const codes = enabledLocales?.value
return codes && codes.length > 0 ? [...codes] : []
})
const activeLocale = ref(locales.value[0] ?? 'en')
const labelledBy = computed(() => `${props.controlId}-label`)
const editorId = computed(() => `${props.controlId}-editor`)
const activeText = computed(() => values.value[activeLocale.value] ?? '')
const markdownField = computed(() => ({ ...props.field, type: 'markdown' }))
function setLocale(locale: string) {
if (!locale) {
return
}
activeLocale.value = locale
}
function onLocaleInput(event: Event) {
const locale = (event.target as HTMLSelectElement).value
setLocale(locale)
broadcastMLLocale(locale)
}
function onText(text: string) {
emit('update:modelValue', { ...values.value, [activeLocale.value]: text })
}
function onBroadcast(event: Event) {
const locale = (event as CustomEvent<string>).detail
if (typeof locale === 'string' && locale !== '') {
setLocale(locale)
}
}
onMounted(() => {
window.addEventListener(ML_LOCALE_CHANGE, onBroadcast)
})
onUnmounted(() => {
window.removeEventListener(ML_LOCALE_CHANGE, onBroadcast)
})
</script>
<template>
<div class="flex flex-col gap-1.5" role="group" :aria-labelledby="labelledBy" data-ml-field="mlmarkdown">
<div class="flex flex-wrap items-center gap-2">
<select
data-ml-locale
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
:value="activeLocale"
:aria-label="`Locale for ${field.label || field.name}`"
@change="onLocaleInput"
>
<option v-for="code in locales" :key="code" :value="code">{{ code }}</option>
</select>
</div>
<MarkdownField
:field="markdownField"
:model-value="activeText"
:control-id="editorId"
:invalid="invalid"
:described-by="describedBy"
@update:model-value="onText"
/>
</div>
<MLField
v-bind="props"
:inner="MarkdownField"
inner-type="markdown"
ml-type="mlmarkdown"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>

View File

@@ -1,77 +1,19 @@
<script setup lang="ts">
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
// The mltext control: MLField around TextField.
import type { FieldControlProps } from '../control'
import MLField from './MLField.vue'
import TextField from './TextField.vue'
import { FORM_ENABLED_LOCALES } from '../formContext'
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
const values = computed(() => localeRecord(props.modelValue))
const enabledLocales = inject(FORM_ENABLED_LOCALES, null)
const locales = computed(() => {
const codes = enabledLocales?.value
return codes && codes.length > 0 ? [...codes] : []
})
const activeLocale = ref(locales.value[0] ?? 'en')
const labelledBy = computed(() => `${props.controlId}-label`)
const editorId = computed(() => `${props.controlId}-editor`)
const activeText = computed(() => values.value[activeLocale.value] ?? '')
function setLocale(locale: string) {
if (!locale) {
return
}
activeLocale.value = locale
}
function onLocaleInput(event: Event) {
const locale = (event.target as HTMLSelectElement).value
setLocale(locale)
broadcastMLLocale(locale)
}
function onText(text: string) {
emit('update:modelValue', { ...values.value, [activeLocale.value]: text })
}
function onBroadcast(event: Event) {
const locale = (event as CustomEvent<string>).detail
if (typeof locale === 'string' && locale !== '') {
setLocale(locale)
}
}
onMounted(() => {
window.addEventListener(ML_LOCALE_CHANGE, onBroadcast)
})
onUnmounted(() => {
window.removeEventListener(ML_LOCALE_CHANGE, onBroadcast)
})
</script>
<template>
<div class="flex flex-col gap-1.5" role="group" :aria-labelledby="labelledBy" data-ml-field="mltext">
<div class="flex flex-wrap items-center gap-2">
<select
data-ml-locale
class="rounded-control border border-border-strong bg-surface px-2 py-1 text-[13px]"
:value="activeLocale"
:aria-label="`Locale for ${field.label || field.name}`"
@change="onLocaleInput"
>
<option v-for="code in locales" :key="code" :value="code">{{ code }}</option>
</select>
</div>
<TextField
:field="{ ...field, type: 'text' }"
:model-value="activeText"
:control-id="editorId"
:invalid="invalid"
:described-by="describedBy"
@update:model-value="onText"
/>
</div>
<MLField
v-bind="props"
:inner="TextField"
inner-type="text"
ml-type="mltext"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
// The mltextarea control: MLField around TextareaField.
import type { FieldControlProps } from '../control'
import MLField from './MLField.vue'
import TextareaField from './TextareaField.vue'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
</script>
<template>
<MLField
v-bind="props"
:inner="TextareaField"
inner-type="textarea"
ml-type="mltextarea"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>

View File

@@ -2,10 +2,10 @@
// filtering, the save payload, 422 detail mapping and focus.
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
import { permissionValues } from './control'
import { localeRecord } from './mlLocale'
import { isMLFieldType, localeRecord } from './mlLocale'
import { isRegistered } from './registry'
export { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from './mlLocale'
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLFieldType, localeRecord } from './mlLocale'
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
export type FormMode = 'create' | 'update' | 'preview'
@@ -65,7 +65,7 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
out[field.name] = permissionValues(field, value)
continue
}
if (field.type === 'mltext' || field.type === 'mlmarkdown') {
if (isMLFieldType(field.type)) {
out[field.name] = localeRecord(value)
continue
}
@@ -111,10 +111,6 @@ export function changedLocales(previous: unknown, next: unknown): string[] {
* whole field when no locale is given, one locale of an ML target otherwise. */
export type PresetEdited = (target: string, locale?: string) => boolean
function isMLType(type: string | undefined): boolean {
return type === 'mltext' || type === 'mlmarkdown'
}
/**
* The preset targets of `source` (fields.yaml `preset`) to rewrite after the
* source changed from `previous` to `next`: target name -> new value. Targets
@@ -140,7 +136,7 @@ export function presetUpdates(
isEdited: PresetEdited,
): AdminRecord {
const out: AdminRecord = {}
const sourceML = isMLType(fields.find((field) => field.name === source)?.type)
const sourceML = isMLFieldType(fields.find((field) => field.name === source)?.type)
for (const field of fields) {
const preset = field.preset
if (!preset || preset.field !== source || (field.type !== 'text' && field.type !== 'mltext')) {
@@ -188,7 +184,7 @@ export function initialValues(fields: FormField[], enabledLocales: readonly stri
out[field.name] = false
} else if (field.type === 'relation' && field.multiple) {
out[field.name] = []
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
} else if (isMLFieldType(field.type)) {
const seed: Record<string, string> = {}
for (const code of enabledLocales) {
seed[code] = ''

View File

@@ -1,3 +1,11 @@
/** The multilingual field types: their value is a locale -> text map. */
export const ML_FIELD_TYPES: ReadonlySet<string> = new Set(['mltext', 'mltextarea', 'mlmarkdown'])
/** Reports whether a field type holds a locale -> text map. */
export function isMLFieldType(type: string | undefined): boolean {
return type !== undefined && ML_FIELD_TYPES.has(type)
}
/** Window event that keeps every ML control on one active locale. */
export const ML_LOCALE_CHANGE = 'summer:ml-locale'

View File

@@ -14,6 +14,9 @@
// server never sends back, so it is empty on load and after every save. The
// permissioneditor control is a value field too: an object of permission code
// to value, labelled as a group.
// The ML controls (mltext, mltextarea, mlmarkdown) are thin shells over one
// locale-switching wrapper, MLField, around the text, textarea and markdown
// controls; each holds a locale -> text map, labelled as a group.
import type { Component } from 'vue'
import CheckboxField from './fields/CheckboxField.vue'
import DatepickerField from './fields/DatepickerField.vue'
@@ -30,6 +33,7 @@ import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import MarkdownField from './fields/MarkdownField.vue'
import MLTextField from './fields/MLTextField.vue'
import MLTextareaField from './fields/MLTextareaField.vue'
import MLMarkdownField from './fields/MLMarkdownField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
import WidgetField from './fields/WidgetField.vue'
@@ -65,6 +69,7 @@ const renderers = new Map<string, Component>([
['permissioneditor', PermissionEditorField],
['markdown', MarkdownField],
['mltext', MLTextField],
['mltextarea', MLTextareaField],
['mlmarkdown', MLMarkdownField],
])
@@ -85,7 +90,7 @@ const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileu
* Types whose control is a group rather than one focusable element: the
* visible label is a span the group points at, not a label for an input.
*/
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mlmarkdown'])
const groupLabelledTypes = new Set<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mltextarea', 'mlmarkdown'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField

View File

@@ -25,6 +25,7 @@ import {
fieldErrors,
focusField,
initialValues,
isMLFieldType,
mergeMLValue,
panelDomId,
snapshot,
@@ -217,7 +218,7 @@ function adopt(record: AdminRecord, meta?: RecordMeta): void {
for (const field of fields.value) {
if (field.type === 'password') {
next[field.name] = ''
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
} else if (isMLFieldType(field.type)) {
next[field.name] = mergeMLValue(record[field.name], locales)
}
}

View File

@@ -24,6 +24,7 @@ import {
fieldErrors,
focusField,
initialValues,
isMLFieldType,
mergeMLValue,
panelDomId,
presetUpdates,
@@ -177,7 +178,7 @@ function adopt(record: RecordEnvelope | undefined): void {
for (const field of fields.value) {
if (field.type === 'password') {
next[field.name] = ''
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
} else if (isMLFieldType(field.type)) {
next[field.name] = mergeMLValue(record.data[field.name], locales)
}
}
@@ -221,7 +222,7 @@ async function load(): Promise<void> {
const edited = new Set<string>()
// The locales of each ML field the administrator edited by hand.
const editedLocales = new Map<string, Set<string>>()
// The locale every ML control shows (MLTextField broadcasts the choice).
// The locale every ML control shows (any ML control broadcasts the choice).
const mlLocale = ref('en')
function firstMLLocale(): string {
@@ -249,7 +250,7 @@ function update(name: string, value: unknown): void {
const next = { ...values.value, [name]: value }
edited.add(name)
const type = fields.value.find((field) => field.name === name)?.type
if (type === 'mltext' || type === 'mlmarkdown') {
if (isMLFieldType(type)) {
let locales = editedLocales.get(name)
if (!locales) {
locales = new Set<string>()