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:
93
admin/src/components/form/fields/MLField.vue
Normal file
93
admin/src/components/form/fields/MLField.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
admin/src/components/form/fields/MLTextareaField.vue
Normal file
19
admin/src/components/form/fields/MLTextareaField.vue
Normal 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>
|
||||
Reference in New Issue
Block a user